Skip to content
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

THREESCALE-6412: Fix admin member user permissions for services (also THREESCALE-11202) #3929

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/controllers/api/plan_copies_controller.rb
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# frozen_string_literal: true

class Api::PlanCopiesController < FrontendController
before_action :find_plan
before_action :find_service
before_action :authorize_section, only: %i[new create]
before_action :authorize_action, only: %i[new create]
before_action :find_plan
Copy link
Contributor Author

Choose a reason for hiding this comment

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

After fixing the permission issue for services, the controller started responding with 404 when 403 was expected (i.e. the service is not allowed AND the required permission is missing).

So, swapping these actions results in the expected behavior, and is more in line with some other controllers.

before_action :find_service

def create
@plan = @original.copy(params[@type] || {})
Expand Down
9 changes: 7 additions & 2 deletions app/models/contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,15 @@ def self.issued_by(issuer, *ids)
where.has { plan_id.in( scope ) }
end

# FIXME: this probably shouldn't return `all`, it's dangerous if it is accidentally
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't really want to fix it in this PR as it is already too long. And it was already this way before...

Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe use scope provider_account: user.provider_account although if we don't have these fields and I think we don't, then it may be complicated. So for now it is not worse than before at least.

# used as a separate scope, and not merged with other scopes that filters by account previously
scope :permitted_for, ->(user) {
next all unless user.forbidden_some_services?
permitted_services_status = user.permitted_services_status
next none if permitted_services_status == :none

where(service_id: user.member_permission_service_ids)
next all if permitted_services_status == :all

merge(permitted_services_status == :selected ? where(service_id: user.member_permission_service_ids) : {})
Comment on lines -66 to +73
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you explain what is the actual difference in behavior compared to before. I don't really understand it and I can't properly review if I don't understand this.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Well, I'll try...

From the user point of view, the issue was that a freshly created member that has NO permissions defined at all had access to some pages - specifically mapping rules, policies, metrics etc. They were not visible from the menus, but using a direct link was possible.

The main reason was in this scope, together with the cancan definition:

can %i[read show edit update], Service, user.accessible_services.where_values_hash

So, if the user had no :services permission, it was automatically considered that it had access to all services, regardless of whether they had permissions such as partners, plans or monitoring.

Currently, it is considered that the user has access to all services ONLY if in addition to not having :services permission there is at least one of the "per-service" permissions (partners, plans or monitoring).

So, a newly created member, that does not have any permissions at all will not be considered as "being able to see all services".

Copy link
Contributor

Choose a reason for hiding this comment

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

Currently, it is considered that the user has access to all services ONLY if in addition to not having :services permission there is at least one of the "per-service" permissions (partners, plans or monitoring).

Do you have a reference for this?

Copy link
Contributor Author

@mayorova mayorova Nov 19, 2024

Choose a reason for hiding this comment

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

Well, it's not that straightforward, you kind of need to follow the whole chain. So, this is what the scope use to say:

self.merge(
      account_services.merge(user.forbidden_some_services? ? where(id: user.member_permission_service_ids) : {})
    )

user.forbidden_some_services? call !has_access_to_all_services?, has_access_to_all_services calls !admin_sections.include?(:services).

So, if admin_sections does NOT include :services (which happens to a member with no permissions at all), has_access_to_all_services == true, user.forbidden_some_services? == false, so the scope will get resolved to:

account_services.merge({})

which basically means where(account_id: {some_id}), which returns all provider's services.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To clarify, the above is for the Service.permitted_for scope, not for the Contract.

But when testing I realised the Contract.permitted_for was wrong also.

}

# Return contracts bought by given account.
Expand Down
8 changes: 6 additions & 2 deletions app/models/service.rb
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,14 @@ def cinstance
scope :permitted_for, ->(user = nil) {
next all unless user

permitted_services_status = user.permitted_services_status

next none if permitted_services_status == :none

account = user.account
account_services = (account.provider? ? account : account.provider_account).services
self.merge(
account_services.merge(user.forbidden_some_services? ? where(id: user.member_permission_service_ids) : {})
merge(
account_services.merge(permitted_services_status == :selected ? where(id: user.member_permission_service_ids) : {})
)
}

Expand Down
44 changes: 27 additions & 17 deletions app/models/user/permissions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
module User::Permissions
extend ActiveSupport::Concern

ATTRIBUTES = %I[ role member_permission_ids member_permission_service_ids ]
ATTRIBUTES = %I[role member_permission_ids member_permission_service_ids].freeze

included do
has_many :member_permissions, dependent: :destroy, autosave: true
Expand All @@ -16,15 +16,12 @@ module User::Permissions
alias_attribute :allowed_service_ids, :member_permission_service_ids
end

#TODO: this is repeated from bcms_hacks plugins because of some loading problem
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This bcms ("browser CMS") thing was removed long time ago, so this comment is not relevant anymore.

def has_permission?(permission)
return true if account.provider? && admin?
return true if account.provider? && admin?
return false if account.buyer?

# check = Permission.count(:include => {:groups => :users}, :conditions => ["users.id = ? and permissions.name=?", id, permission]) > 0

admin_sections.include?(permission.to_sym).tap do |check|
logger.info "~> #{username} has_permission?(#{permission}) => #{check}"
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I don't think it's nice to pollute logs with these unnecessary logs 😬

logger.debug "~> #{username} has_permission?(#{permission}) => #{check}"
end
end

Expand Down Expand Up @@ -60,11 +57,11 @@ def member_permission_ids=(roles)
end

def member_permission_service_ids=(service_ids)
if service_ids.present?
service_ids = Array(service_ids).compact.map(&:to_i)
if service_ids.is_a? Array
Copy link
Contributor Author

@mayorova mayorova Oct 24, 2024

Choose a reason for hiding this comment

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

API and UI send these values in a "funny way", like ["[]"] and [""], because of the URL-encoded params limitations.

I changed the way the service_ids value is treated, so that it is less confusing to update the param directly with an update call.

So member_permission_service_ids: [] sets "no services are allowed", while member_permission_service_ids: nil sets to "all services are allowed".

While the API/UI behavior seems to be still OK.

service_ids = service_ids.compact_blank.map(&:to_i)
member_permission = services_member_permission || member_permissions.build(admin_section: :services)
member_permission.service_ids = service_ids & existing_service_ids
else
elsif service_ids.blank?
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This allows us to set member_permission_service_ids: nil in the code and member_permission_service_ids: "" via API and UI to allow all services.

self.member_permissions = member_permissions - [services_member_permission].compact
end
ensure
Expand All @@ -80,6 +77,7 @@ def existing_service_ids
# returns [] if no services are enabled, and nil if all (current and future) services are enabled
def member_permission_service_ids
return nil if admin? || !services_member_permission

permitted_service_ids = services_member_permission.try(:service_ids) || []
permitted_service_ids & existing_service_ids
end
Expand All @@ -89,22 +87,34 @@ def services_member_permission
end

def has_access_to_service?(service)
services_permission = services_member_permission
services_permission && services_permission.has_service?(service) || has_access_to_all_services?
services_member_permission&.has_service?(service) || has_access_to_all_services?
end

# Returns:
# :none - if no services are allowed
# :all - if all services are allowed for the selected service-related permissions
# :selected - if a subset of services is allowed for the selected service-related permissions
def permitted_services_status
if admin? || (service_permissions_selected? && member_permission_service_ids.nil?)
:all
elsif service_permissions_selected? && member_permission_service_ids&.any?
:selected
else
:none
end
end

# Lack of the services section means it is the old permission system where everyone had access
# to every service. So to limit the scope only for new users, we start adding this permission.
def has_access_to_all_services?
!admin_sections.include?(:services) || admin?
permitted_services_status == :all
end

def forbidden_some_services?
!has_access_to_all_services? && account.provider_can_use?(:service_permissions)
# Returns whether the user has access to any service-related permissions (partners, plans or monitoring)
def service_permissions_selected?
(member_permission_ids & AdminSection::SERVICE_PERMISSIONS).any?
end

def access_to_service_admin_sections?
(member_permission_ids & AdminSection::SERVICE_PERMISSIONS).any? && accessible_services?
service_permissions_selected? && accessible_services?
end

def reload(*)
Expand Down
3 changes: 2 additions & 1 deletion config/abilities/provider_any.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
cannot %i[destroy update_role], user

# Services
can %i[read show edit update], Service, user.accessible_services.where_values_hash
user_accessible_services = user.accessible_services
can %i[read show edit update], Service, user_accessible_services.where_values_hash unless user_accessible_services.is_a? ActiveRecord::NullRelation
Copy link
Contributor Author

Choose a reason for hiding this comment

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

user_accessible_services.is_a? ActiveRecord::NullRelation was added to support next none line in the Service's permitted_for scope.

Otherwise, in case "no services are allowed", while user.accessible_services returns an empty array, user.accessible_services.where_values_hash is {}, which makes cancancan authorize ALL services (because there is no query conditions).

Adding this unless condition makes it possible to prohibit all services if permitted_for returns none


#
# Events
Expand Down
5 changes: 3 additions & 2 deletions config/abilities/provider_member.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,11 @@

# Using historical optimized way and leave canonical way (through plan) commented out below
# The resulting hash presently is something like {"type"=>"Cinstance", "service_id"=>[ids..]}
can %i[read show edit update], Cinstance, Cinstance.permitted_for(user).where_values_hash
permitted_cinstances = Cinstance.permitted_for(user)
can %i[read show edit update], Cinstance, permitted_cinstances.where_values_hash unless permitted_cinstances.is_a? ActiveRecord::NullRelation
# can %i[read show edit update], Cinstance, user.accessible_cinstances do |cinstance|
# cinstance.plan&.issuer_type == "Service" && cinstance.plan.issuer.account == user.account &&
# (!user.forbidden_some_services? || user.member_permission_service_ids.include?(cinstance.plan.issuer.id))
# (user.permitted_services_status == :selected || user.member_permission_service_ids.include?(cinstance.plan.issuer.id))
# end

# abilities for buyer users
Expand Down
2 changes: 1 addition & 1 deletion doc/active_docs/account_management_api.json
Original file line number Diff line number Diff line change
Expand Up @@ -2812,7 +2812,7 @@
},
"allowed_service_ids[]": {
"type": "array",
"description": "IDs of the services that need to be enabled, URL-encoded array. To disable all services, set the value to '[]'. To enable all services, add parameter 'allowed_service_ids[]' with no value to the 'curl' command (can't be done in ActiveDocs)",
"description": "IDs of the services that need to be enabled, URL-encoded array. To disable all services, set the value to '[]'. To enable all services, add parameter 'allowed_service_ids' with no value to the 'curl' command (can't be done in ActiveDocs)",
"items": {
"type": "integer"
}
Expand Down
2 changes: 1 addition & 1 deletion spec/acceptance/api/member_permissions_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@

shared_context "all services disabled" do
before do
user.member_permission_service_ids = "[]"
user.member_permission_service_ids = []
end
end

Expand Down
8 changes: 4 additions & 4 deletions test/integration/api/application_plans_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -239,13 +239,13 @@ class ProviderMemberTest < self
get new_admin_service_application_plan_path(service)
assert_response :forbidden

post admin_service_application_plans_path(service), params: { application_plan:{ name: 'planName' } }
post admin_service_application_plans_path(service), params: { application_plan: { name: 'planName' } }
assert_response :forbidden

get edit_admin_application_plan_path(plan)
assert_response :forbidden

put admin_application_plan_path(plan), params: { application_plan:{ name: 'New plan name' } }
put admin_application_plan_path(plan), params: { application_plan: { name: 'New plan name' } }
assert_response :forbidden

post masterize_admin_service_application_plans_path(service)
Expand Down Expand Up @@ -274,13 +274,13 @@ class ProviderMemberTest < self
get new_admin_service_application_plan_path(service)
assert_response :success

post admin_service_application_plans_path(service), params: { application_plan:{ name: 'planName' } }
post admin_service_application_plans_path(service), params: { application_plan: { name: 'planName' } }
assert_response :redirect

get edit_admin_application_plan_path(plan)
assert_response :success

put admin_application_plan_path(plan), params: { application_plan:{ name: 'New plan name' } }
put admin_application_plan_path(plan), params: { application_plan: { name: 'New plan name' } }
assert_response :redirect

post masterize_admin_service_application_plans_path(service)
Expand Down
6 changes: 2 additions & 4 deletions test/integration/api/backend_usages_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -109,15 +109,13 @@ class Api::BackendUsagesControllerTest < ActionDispatch::IntegrationTest
get admin_service_backend_usages_path(service)
assert_response :success

member.member_permission_service_ids = []
member.save!
member.update(member_permission_service_ids: [])

get admin_service_backend_usages_path(service)
assert_response :success

other_service = FactoryBot.create(:simple_service, account: provider)
member.member_permission_service_ids = [other_service.id]
member.save!
member.update(member_permission_service_ids: [other_service.id])

get admin_service_backend_usages_path(service)
assert_response :not_found
Expand Down
2 changes: 1 addition & 1 deletion test/integration/api/integrations_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ def setup
login! provider, user: member

get admin_service_integration_path(service_id: service.id)
assert_response 403
assert_response 404

member.member_permissions.create!(admin_section: 'plans')
get admin_service_integration_path(service_id: service.id)
Expand Down
11 changes: 11 additions & 0 deletions test/integration/api/policies_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,15 @@ def setup
get edit_admin_service_policies_path(@service)
assert_response :service_unavailable
end

test 'policies edit for members with no permissions' do
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@akostadinov I applied your suggestion for memoization and added tests for the 2 pages mentioned in the JIRA description. They fail on master - return a 200 with full details, and pass in this branch.

Copy link
Contributor

Choose a reason for hiding this comment

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

About this quote in the PR description:

the user will be able to modify the policy chain (through the direct link), even if they don't have this specific permission set - having any type of access to the service would be enough.

That's actually not true, right? if I try to access /apiconfig/services/2/policies/edit I this branch I get a 404 now, while I can access in master branch. This test is precisely for that.

So if it's fixed, why not removing this?:

can(:manage, :policy_registry) if account.tenant? && account.provider_can_use?(:policy_registry)
can(:manage, :policy_registry_ui) if account.tenant? && account.provider_can_use?(:policy_registry_ui)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, indeed, as accessible_services is now returning an empty array (after fixing the permitted_for scope), fixing this is a side effect.

As mentioned in the PR description, :policy_registry_ui doesn't seem to be used, so probably I'll remove it. However, :policy_registry might be used somewhere else (maybe the API?), so, I'll keep it as it is.

Policies::PoliciesListService.unstub(:call!)
Policies::PoliciesListService.expects(:call!).never
member = FactoryBot.create(:member, account: @provider, state: 'active')
logout! && login!(@provider, user: member)

get edit_admin_service_policies_path(@service)

assert_response :not_found
end
end
10 changes: 10 additions & 0 deletions test/integration/api/proxy_configs_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,14 @@ def setup
assert_response :success
assert_equal '{"foo":"bar"}', response.body
end

test 'proxy configs index for members with no permissions' do
member = FactoryBot.create(:member, account: @provider, state: 'active')
logout! && login!(@provider, user: member)

get admin_service_proxy_configs_path(service_id: service, environment: 'sandbox')

# TODO: maybe this should be be a :forbidden
Copy link
Contributor

Choose a reason for hiding this comment

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

I agree, but, why does it return a 404? how did your changes affect routes?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yeah, right, the thing is that the first thing that the controller does is trying to find a service:

@service ||= current_user.accessible_services.find(params[:service_id])

And as accessible_services scope has changed, now the service is not found in that list.

That's also the reason why all of these controllers return 404 and not 403 when trying to access - they all load accessible_services and if they don't find the requested ID there, they just return 404.

I am not sure if it's worth fixing to be honest. And actually it also makes sense - the user doesn't need to know whether the service exists or not, if they don't have permission for it - it's as if it doesn't exist for them 😬

Copy link
Contributor

Choose a reason for hiding this comment

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

I think returning 403 or 404 both are valid:

https://www.rfc-editor.org/rfc/rfc7231#section-6.5.3

An origin server that wishes to "hide" the current existence of a forbidden target resource MAY instead respond with a status code of 404 (Not Found).

assert_response :not_found
end
end
6 changes: 3 additions & 3 deletions test/integration/buyers/accounts_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,17 @@ def test_show
cinstance = service.cinstances.last
cinstance.update(name: 'Alaska Application App')

User.any_instance.expects(:has_access_to_all_services?).returns(true).at_least_once
assert_nil user.member_permission_service_ids
get admin_buyers_account_path(buyer)
assert_response :success
assert_match 'Alaska Application App', response.body

User.any_instance.expects(:has_access_to_all_services?).returns(false).at_least_once
user.update(member_permission_service_ids: [])
get admin_buyers_account_path(buyer)
assert_response :success
assert_not_match 'Alaska Application App', response.body

User.any_instance.expects(:member_permission_service_ids).returns([service.id]).at_least_once
user.update(member_permission_service_ids: [service.id])
get admin_buyers_account_path(buyer)
assert_response :success
assert_match 'Alaska Application App', response.body
Expand Down
2 changes: 1 addition & 1 deletion test/integration/master/providers/plans_controller_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ def setup
end

test '#update for a member with permission partners but without the service' do
master_member.update!({member_permission_ids: ['partners'], member_permission_service_ids: '[]'})
master_member.update!({member_permission_ids: ['partners'], member_permission_service_ids: []})
login! master_account, user: master_member

put master_provider_plan_path(tenant), params: { plan_id: new_plan.id, format: :js }
Expand Down
18 changes: 7 additions & 11 deletions test/integration/stats/applications_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,37 +8,33 @@ def setup
@service = @provider.default_service
@plan = FactoryBot.create(:simple_application_plan, issuer: @service)
@application = FactoryBot.create(:simple_cinstance, plan: @plan)
@member = FactoryBot.create(:member, account: @provider, member_permission_ids: %i[partners plans], state: 'active')

host! @provider.external_admin_domain
login_provider @provider
login_provider @provider, user: @member
end

test '#show nonexistent application does not check permissions' do
User.any_instance.expects(:has_access_to_all_services?).never
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Throughout the tests, I removed stubbing has_access_to_all_services? and opted for actually setting the proper permissions for the member. I think it is less confusing, and more reliable.

I might eventually remove this method...

all_services_allowed? could be a good replacement for it, as it essentially returns the same - but in a correct way :) (i.e. not returning true for members that don't have ANY permissions). However, stubbing it is not really useful, because the Service.permitted_for scope doesn't rely on it.

That's why I actually think that rather than stub the methods, we can just set the proper state of the object.

User.any_instance.expects(:member_permission_service_ids).never

get admin_buyers_stats_application_path(id: 'foo')
assert_response :not_found
end

test '#show does not check member permission with access to all services' do
User.any_instance.expects(:has_access_to_all_services?).returns(true).at_least_once
User.any_instance.expects(:member_permission_service_ids).never
test '#show succeeds with access to all services' do
assert_nil @member.member_permission_service_ids
get admin_buyers_stats_application_path(id: @application.id)
assert_response :success
end

test '#show needs member permission' do
User.any_instance.expects(:has_access_to_all_services?).returns(false)
User.any_instance.expects(:access_to_service_admin_sections?).returns(false)
User.any_instance.expects(:member_permission_service_ids).returns([@service.id]).at_least_once
test '#show succeeds with permission for a specific service' do
@member.update(member_permission_service_ids: [@service.id])
get admin_buyers_stats_application_path(id: @application.id)
assert_response :success
end

test '#show is forbidden without member permission' do
User.any_instance.expects(:has_access_to_all_services?).returns(false)
User.any_instance.expects(:member_permission_service_ids).returns([]).at_least_once
@member.update(member_permission_service_ids: [])
get admin_buyers_stats_application_path(id: @application.id)
assert_response :forbidden
end
Expand Down
10 changes: 4 additions & 6 deletions test/integration/stats/data/requests_to_api_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,21 +35,19 @@ def setup

token.scopes = ['stats']
token.save!
member.admin_sections = []
member.save!

# member does not have the right permission
member.update(member_permission_ids: [])
get usage_stats_data_applications_path(@application, format: :json), params: params
assert_response :forbidden

member.admin_sections = ['monitoring']
member.save!
User.any_instance.expects(:has_access_to_all_services?).returns(false).at_least_once
# the service is not accessible for the member
member.update(member_permission_ids: [:monitoring], member_permission_service_ids: [])
get usage_stats_data_applications_path(@application, format: :json), params: params
assert_response :forbidden

User.any_instance.expects(:member_permission_service_ids).returns([@service.id]).at_least_once
# the service is accessible for the member
member.update(member_permission_service_ids: [@service.id])
get usage_stats_data_applications_path(@application, format: :json), params: params
assert_response :success
end
Expand Down
Loading