Skip to content

[PD-10597][PD-12400] GraphQL Backend Restrictions #57

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 10 commits into from
11 changes: 10 additions & 1 deletion lib/hq/graphql/ext/object_extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,16 @@ def field_from_column(column, auto_nil:)
name = column.name
return if field_exists?(name)

field name, Types.type_from_column(column), null: !auto_nil || column.null
field name, Types.type_from_column(column), null: !auto_nil || column.null,
authorize: -> (_obj, ctx) do
restriction = ctx[:current_user]&.restrictions&.detect do |el|
(el.resource.name == name || el.resource.alias == name) &&
el.restriction_operation_id == "HasHelpers::RestrictionOperation::::View" &&
el.resource.resource_type_id != "HasHelpers::ResourceType::::RequiredField"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to check RequiredField to view?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

required fields can't be restricted because this will cause a schema conflict. For example if attribute name is required, the schema assures that this attribute will always be returned as string. If it has a restriction, it will try to return null, causing a query fail for schema error

end
return false if restriction.present?
true
end
end

def field_exists?(name)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ module GraphQL
module FieldExtension
class AssociationLoaderExtension < ::GraphQL::Schema::FieldExtension
def resolve(object:, **_kwargs)
restriction = _kwargs[:context][:current_user]&.restrictions&.detect do |el|
el.restriction_operation_id == "HasHelpers::RestrictionOperation::::View" &&
(((el.resource.name == field.original_name.camelize || el.resource.alias == field.original_name.camelize) &&
el.resource_type_id == "HasHelpers::ResourceType::::BaseResource") ||
((el.resource.parent&.name == options[:klass].name || el.resource.parent&.alias == options[:klass].name) &&
el.resource.field_class_name == field.original_name.camelize || el.resource.alias == field.original_name.camelize))
end
return {} if restriction.present?
AssociationLoader.for(options[:klass], field.original_name).load(object.object)
end
end
Expand Down
9 changes: 9 additions & 0 deletions lib/hq/graphql/field_extension/paginated_loader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ def resolve(object:, arguments:, **_options)
sort_order: sort_order
)

restriction = _options[:context][:current_user]&.restrictions&.detect do |el|
(el.restriction_operation_id == "HasHelpers::RestrictionOperation::::View" &&
((el.resource.name == field.original_name.camelize || el.resource.alias == field.original_name.camelize) &&
el.resource_type_id == "HasHelpers::ResourceType::::BaseResource") ||
((el.resource.parent&.name == options[:klass].name || el.resource.parent&.alias == options[:klass].name) &&
el.resource.field_class_name == field.original_name.camelize || el.resource.alias == field.original_name.camelize))
end
return {} if restriction.present?

loader.load(object.object)
end

Expand Down
174 changes: 170 additions & 4 deletions lib/hq/graphql/resource/auto_mutation.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,18 @@ def build_create

build_mutation(action: :create) do
define_method(:resolve) do |**args|
return {
resource: nil,
errors: { "resource" => "Unauthorized action for #{self.class.graphql_name.underscore.humanize}" }
} if is_base_restricted(scoped_self.model_name, "HasHelpers::RestrictionOperation::::Create")

resource = scoped_self.new_record(context)
resource.assign_attributes(args[:attributes].format_nested_attributes)
result = attribute_restrictions_handler(scoped_self.model_name, args[:attributes], "HasHelpers::RestrictionOperation::::Create")
resource.assign_attributes(result[:filtered_attrs])
if resource.save
{
resource: resource,
errors: {},
errors: result[:errors],
}
else
{
Expand All @@ -39,14 +45,21 @@ def build_update

build_mutation(action: :update, require_primary_key: true) do
define_method(:resolve) do |**args|
return {
resource: nil,
errors: { "resource" => "Unauthorized action for #{self.class.graphql_name.underscore.humanize}" }
} if is_base_restricted(scoped_self.model_name, "HasHelpers::RestrictionOperation::::Update")

resource = scoped_self.find_record(args, context)

result = attribute_restrictions_handler(scoped_self.model_name, args[:attributes], "HasHelpers::RestrictionOperation::::Update")

if resource
resource.assign_attributes(args[:attributes].format_nested_attributes)
resource.assign_attributes(result[:filtered_attrs])
if resource.save
{
resource: resource,
errors: {},
errors: result[:errors],
}
else
{
Expand All @@ -73,6 +86,11 @@ def build_copy

build_mutation(action: :copy, require_primary_key: true, nil_klass: true) do
define_method(:resolve) do |**args|
return {
resource: nil,
errors: { "resource" => "Unauthorized action for #{self.class.graphql_name.underscore.humanize}" }
} if is_base_restricted(scoped_self.model_name, "HasHelpers::RestrictionOperation::::Copy")

resource = scoped_self.find_record(args, context)

if resource
Expand Down Expand Up @@ -103,6 +121,11 @@ def build_destroy

build_mutation(action: :destroy, require_primary_key: true) do
define_method(:resolve) do |**attrs|
return {
resource: nil,
errors: { "resource" => "Unauthorized action for #{self.class.graphql_name.underscore.humanize}" }
} if is_base_restricted(scoped_self.model_name, "HasHelpers::RestrictionOperation::::Delete")

resource = scoped_self.find_record(attrs, context)

if resource
Expand Down Expand Up @@ -156,6 +179,149 @@ def build_mutation(action:, require_primary_key: false, nil_klass: false, &block
def errors_from_resource(resource)
resource.errors.to_h.deep_transform_keys { |k| k.to_s.camelize(:lower) }
end

# return all restrictions related to a resource of type BaseResource, filtered by restriction operation type
# restriction_operations is an array of ::HasHelpers::RestrictionOperation
def get_base_restrictions(restriction_operations)
restrictions = context[:current_user]&.restrictions&.select do |el|
(el.resource.resource_type_id == "HasHelpers::ResourceType::::BaseResource" &&
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

memoize base_restrictions thus is always the same

@base_restrictions ||=  context[:current_user]&.restrictions&.select do |el|
     (el.resource.resource_type_id == "HasHelpers::ResourceType::::BaseResource")
end
restrictions = @base_restrictions.select { |r|  restriction_operations.include?(r.restriction_operation_id) }

restriction_operations.include?(el.restriction_operation_id))
end
restrictions
Copy link
Member

@kylejginavan kylejginavan Aug 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we using ruby here instead of sql? I would add specs then convert to SQL. Same comment applies to many of the functions in this file.

end

# Return true if a restriction related to a specific BaseResource, filtered by restriction operation type exist
# model_name is an specific resource used for filter restrictions
# restriction_operation is a ::HasHelpers::RestrictionOperation type
def is_base_restricted(association_name, restriction_operation)
association_name = association_name.demodulize
restriction = get_base_restrictions([restriction_operation])&.detect do |el|
el.resource.name == association_name
end
restriction.present?
end

# Return all attribute restrictions related to a specific resource and also all BaseResource restrictions,
# both filtered by restriction operation
# association_name is an specific resource used for filter restrictions
# restriction_operations is an array of ::HasHelpers::RestrictionOperation
def get_attributes_restrictions(association_name, restriction_operations)
restrictions = context[:current_user]&.restrictions&.select do |el|
((el.resource.parent&.name == association_name &&
el.resource.parent&.resource_type_id == "HasHelpers::ResourceType::::BaseResource") ||
el.resource.resource_type_id == "HasHelpers::ResourceType::::BaseResource") &&
restriction_operations.include?(el.restriction_operation_id)
end

restrictions
end

def get_excluded_attrs(filtered_attrs, restrictions, restriction_operation, is_root)
restrictions.select do |r|
selected_restriction_operation = is_root ? restriction_operation.first : (
filtered_attrs.key?("id") ? "HasHelpers::RestrictionOperation::::Update" : "HasHelpers::RestrictionOperation::::Create"
)
filtered_attrs.with_indifferent_access.key?(r[0]) && r[1] == selected_restriction_operation
end.reject { |c| c.empty? }
end

# Return filtered nested attributes and a list of the restricted attributes,
# based on restrictions
# model_name is used to specify the model inside the restricted attributes
# attr is the object that can have nested attributes
# attr_restrictions are the restrictions related to the model_name
# filtered_attrs are the initial attributes
# restricted_attrs are the initial restricted attributes.
def apply_restrictions_in_nested(model_name, attr, attr_restrictions, filtered_attrs, restricted_attrs)
nested_attributes = attr.keys.select { |el| el.to_s.include?("_attributes") }
nested_attributes.each do |el|
nested_attr_name = el.gsub("_attributes", "").classify
# check if restrictions of create/edit and/or assign nested resource exist
# if restrictions exist, remove nested attribute from filtered_attrs
# else search restricted attributes from nested attribute recursively
filtered_attr_restrictions = attr_restrictions&.detect do |ar|
ar.resource.name == nested_attr_name || ar.resource.name.gsub("_id", "").classify == nested_attr_name
end
if filtered_attr_restrictions.present?
restricted_attrs[model_name.camelize(:lower)].push(nested_attr_name.camelize(:lower) => "don't have permissions to create/edit")
filtered_attrs = filtered_attrs.except(el)
else
result = recursive_nested_restrictions(
nested_attr_name, filtered_attrs[el],
{ nested_attr_name.camelize(:lower) => [] },
["HasHelpers::RestrictionOperation::::Create", "HasHelpers::RestrictionOperation::::Update"]
)
filtered_attrs[el] = result[:filtered_attrs]
filtered_attrs = filtered_attrs.except(el) if filtered_attrs[el].blank?
restricted_attrs[model_name.camelize(:lower)].push(result[:restricted_attrs]) if result[:restricted_attrs].present?
end
end
{ filtered_attrs: filtered_attrs, restricted_attrs: restricted_attrs }
end

# Returns filtered mutation arguments based on restrictions and a list of filtered arguments
# model_name is an specific resource used for filter restrictions
# filtered_attrs are mutation's nested arguments
# restricted_attrs list of arguments that are restricted based on restrictions
# restriction_operation is a ::HasHelpers::RestrictionOperation type
def recursive_nested_restrictions(model_name, filtered_attrs, restricted_attrs, restriction_operation, is_root = false)
# gets attribute restrictions of a resource
# if restrictions exist, add related args to restricted_attrs array and removes those args from filtered_attrs
restrictions = get_attributes_restrictions(model_name, restriction_operation)
restrictions = restrictions.map { |el| [el.resource.name, el.restriction_operation_id] } if !restrictions.empty?
if restrictions.present?
excluded_attrs = filtered_attrs.kind_of?(Array) ?
filtered_attrs.map { |attr| get_excluded_attrs(attr, restrictions, restriction_operation, is_root) }.reject { |c| c.empty? } :
get_excluded_attrs(filtered_attrs, restrictions, restriction_operation, is_root)

restricted_attrs[model_name.camelize(:lower)] += excluded_attrs.map do |el|
{ el[0].camelize(:lower) => "don't have permissions to #{el[1].demodulize.camelize(:lower)}" }
end if excluded_attrs.present?

filtered_attrs = filtered_attrs.kind_of?(Array) ?
filtered_attrs.map { |el| el.with_indifferent_access.except(*restrictions.flatten) } :
filtered_attrs.with_indifferent_access.except(*restrictions.flatten) if restrictions.present?
end

# if there's an association for create/update, checks the existance of related restrictions
# if restrictions exist, add related args to restricted_attrs array and removes those args from filtered_attrs
attr_restrictions = (
get_base_restrictions(["HasHelpers::RestrictionOperation::::Create", "HasHelpers::RestrictionOperation::::Update"]) +
get_attributes_restrictions(model_name, ["HasHelpers::RestrictionOperation::::Create", "HasHelpers::RestrictionOperation::::Update"])
)
if filtered_attrs.kind_of?(Array)
filtered_attrs.each_with_index do |attr, idx|
result = apply_restrictions_in_nested(model_name, attr, attr_restrictions, filtered_attrs[idx], restricted_attrs)
filtered_attrs[idx] = result[:filtered_attrs]
restricted_attrs = result[:restricted_attrs]
end
filtered_attrs = filtered_attrs.reject { |c| c.blank? }
else
filtered_attrs, restricted_attrs = apply_restrictions_in_nested(model_name, filtered_attrs, attr_restrictions, filtered_attrs, restricted_attrs).
values_at(:filtered_attrs, :restricted_attrs)
end
restricted_attrs[model_name.camelize(:lower)] = restricted_attrs[model_name.camelize(:lower)].uniq
restricted_attrs = restricted_attrs.except(model_name.camelize(:lower)) if restricted_attrs.kind_of?(Hash) && restricted_attrs[model_name.camelize(:lower)].blank?
{ filtered_attrs: filtered_attrs, restricted_attrs: restricted_attrs }
end

# returns mutation args filtered by restrictions and errors warning with all args removed
def attribute_restrictions_handler(model_name, attributes, restriction_operation)
association_name = model_name.demodulize
filtered_attrs = attributes.format_nested_attributes.with_indifferent_access

filtered_attrs, restricted_attrs = recursive_nested_restrictions(
association_name,
filtered_attrs,
{ association_name.camelize(:lower) => [] },
[restriction_operation], true
).values_at(:filtered_attrs, :restricted_attrs) if context[:current_user]&.restrictions.present?

errors = {}
errors = { "warning" => restricted_attrs } if restricted_attrs.present?

{ errors: errors, filtered_attrs: filtered_attrs }
end
end

const_set(gql_name, klass)
Expand Down
2 changes: 1 addition & 1 deletion lib/hq/graphql/version.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,6 @@

module HQ
module GraphQL
VERSION = "2.3.5"
VERSION = "2.3.6"
end
end
10 changes: 10 additions & 0 deletions spec/factories/resources.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
FactoryBot.define do
factory :resource do
name { Faker::Commerce.product_name }
add_attribute(:alias) { @alias ? @alias : @name }
resource_type_id { "::HasHelpers::ResourceType::#{["BaseResource", "Field"].sample}" }
parent { FactoryBot.create(:resource, resource_type_id: "::HasHelpers::ResourceType::::BaseResource") }
field_resource { [FactoryBot.create(:resource, resource_type_id: "::HasHelpers::ResourceType::::BaseResource"), nil].sample }
field_class_name { @field_resource.nil? ? nil : @field_resource&.name }
end
end
8 changes: 8 additions & 0 deletions spec/factories/restrictions.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
FactoryBot.define do
factory :restriction do
resource { FactoryBot.create(:resource) }
role { FactoryBot.create(:role) }
restriction_operation_id { "::HasHelpers::RestrictionOperation::#{["Create","Update", "Destroy", "Copy"].sample}" }
organization { FactoryBot.create(:organization) }
end
end
6 changes: 6 additions & 0 deletions spec/factories/roles.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :role do
name { Faker::Commerce.product_name }
organization { FactoryBot.create(:organization) }
end
end
1 change: 1 addition & 0 deletions spec/factories/users.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
factory :user do
name { Faker::Name.name }
organization { FactoryBot.build(:organization) }
role { FactoryBot.build(:role) }
end
end
1 change: 1 addition & 0 deletions spec/internal/app/models/advisor.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
class Advisor < ActiveRecord::Base
belongs_to :organization
belongs_to :optional_org, class_name: "::Organization", optional: true

def hydrate
end
Expand Down
8 changes: 8 additions & 0 deletions spec/internal/app/models/resource.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
class Resource < ::ActiveRecord::Base
belongs_to :parent, class_name: "::Resource", optional: true
belongs_to :field_resource, class_name: "::Resource", optional: true

has_many :resources, foreign_key: "parent_id", dependent: :destroy, inverse_of: :parent
has_many :resources, foreign_key: "field_resource_id", dependent: :destroy, inverse_of: :field_resource
has_many :restrictions
end
5 changes: 5 additions & 0 deletions spec/internal/app/models/restriction.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
class Restriction < ::ActiveRecord::Base
belongs_to :organization
belongs_to :resource
belongs_to :role
end
6 changes: 6 additions & 0 deletions spec/internal/app/models/role.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
class Role < ::ActiveRecord::Base
belongs_to :organization

has_many :restrictions
has_many :users
end
3 changes: 3 additions & 0 deletions spec/internal/app/models/user.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ class User < ::ActiveRecord::Base
belongs_to :advisor, optional: true
belongs_to :manager, optional: true
belongs_to :organization
belongs_to :role

has_many :restrictions, through: :role

def hydrate
end
Expand Down
Loading