diff --git a/app/controllers/v2/policies_controller.rb b/app/controllers/v2/policies_controller.rb new file mode 100644 index 0000000000..2082905897 --- /dev/null +++ b/app/controllers/v2/policies_controller.rb @@ -0,0 +1,72 @@ +# frozen_string_literal: true + +module V2 + # Controller for Policies + class PoliciesController < ApplicationController + CREATE_ATTRIBUTES = %i[title description compliance_threshold business_objective profile_id].freeze + UPDATE_ATTRIBUTES = %i[description compliance_threshold business_objective].freeze + + def index + render_json compliance_policies + end + permission_for_action :index, Rbac::POLICY_READ + + def show + render_json compliance_policy + end + permission_for_action :show, Rbac::POLICY_READ + + def create + new_policy = Policy.new(resource_params[:attributes].to_h.slice(*CREATE_ATTRIBUTES)) + + if new_policy.save + render_json new_policy, status: :created + audit_success("Created policy #{new_policy.id} with initial profile #{new_profile.id}") + else + render_model_errors new_policy + end + end + + def update + if compliance_policy.update(resource_params[:attributes].to_h.slice(*UPDATE_ATTRIBUTES)) + render_json compliance_policy + audit_success("Updated policy #{compliance_policy.id}") + else + render_model_errors compliance_policy + end + end + + def destroy + compliance_policy.destroy + audit_success("Removed policy #{compliance_policy.id}") + render_json compliance_policy, status: :accepted + end + permission_for_action :create, Rbac::POLICY_DELETE + + private + + def compliance_policies + @compliance_policies ||= authorize(resolve_collection) + end + + def compliance_policy + @compliance_policy ||= authorize(expand_resource.find(permitted_params[:id])) + end + + def resource_params + permitted_params.require(:data).permit(:attributes) + end + + def resource + V2::Policy + end + + def serializer + V2::PolicySerializer + end + + def extra_fields + [:account_id] + end + end +end diff --git a/app/models/v2/account.rb b/app/models/v2/account.rb new file mode 100644 index 0000000000..1673bd4b63 --- /dev/null +++ b/app/models/v2/account.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +module V2 + # Model for user accounts + class Account < ApplicationRecord + has_many :policies, class_name: 'V2::Policy', dependent: :destroy + end +end diff --git a/app/models/v2/policy.rb b/app/models/v2/policy.rb index 678fef53f6..d0aa3fed96 100644 --- a/app/models/v2/policy.rb +++ b/app/models/v2/policy.rb @@ -10,5 +10,33 @@ class Policy < ApplicationRecord belongs_to :profile, class_name: 'V2::Profile' has_one :security_guide, through: :profile, class_name: 'V2::SecurityGuide' has_many :tailorings, class_name: 'V2::Tailoring', dependent: :destroy + belongs_to :account + + validates :account, presence: true + validates :profile, presence: true + validates :title, presence: true + validates :compliance_threshold, numericality: { + greater_than_or_equal_to: 0, less_than_or_equal_to: 100 + } + + sortable_by :title + # sortable_by :os_major_version # TODO: this needs to be made compatible with `expand_resource` + # sortable_by :host_count # TODO: this can be turned on after we have ways to assign hosts + sortable_by :business_objective + sortable_by :compliance_threshold + + scoped_search on: :title, only_explicit: true, operators: %i[like unlike eq ne in notin] + + def os_major_version + attributes['security_guide__ref_id'].try(:[], SecurityGuide::OS_MAJOR_RE)&.to_i || security_guide.os_major_version + end + + def profile_title + attributes['profile__title'] || profile.title + end + + def ref_id + attributes['profile__ref_id'] || profile.ref_id + end end end diff --git a/app/models/v2/security_guide.rb b/app/models/v2/security_guide.rb index 2a3258f2bb..e333ed825c 100644 --- a/app/models/v2/security_guide.rb +++ b/app/models/v2/security_guide.rb @@ -6,6 +6,8 @@ class SecurityGuide < ApplicationRecord # FIXME: clean up after the remodel self.primary_key = :id + OS_MAJOR_RE = /(?<=RHEL-)\d+/ + SORT_BY_VERSION = Arel::Nodes::NamedFunction.new( 'CAST', [ @@ -48,7 +50,7 @@ class SecurityGuide < ApplicationRecord sortable_by :os_major_version, SORT_BY_OS_MAJOR_VERSION def os_major_version - ref_id[/(?<=RHEL-)\d+/].to_i + ref_id[OS_MAJOR_RE].to_i end def self.os_major_version_search(_filter, operator, value) diff --git a/app/models/v2/tailoring.rb b/app/models/v2/tailoring.rb index 7cd1a8b86b..344e1a3aa2 100644 --- a/app/models/v2/tailoring.rb +++ b/app/models/v2/tailoring.rb @@ -10,5 +10,10 @@ class Tailoring < ApplicationRecord belongs_to :policy, class_name: 'V2::Policy' belongs_to :profile, class_name: 'V2::Profile' has_one :security_guide, through: :profile, class_name: 'V2::SecurityGuide' + has_one :account, through: :policy, class_name: 'V2::Account' + + validates :policy, presence: true + validates :profile, presence: true + validates :os_minor_version, numericality: { greater_than_or_equal_to: 0 } end end diff --git a/app/policies/v2/policy_policy.rb b/app/policies/v2/policy_policy.rb new file mode 100644 index 0000000000..3c44f23015 --- /dev/null +++ b/app/policies/v2/policy_policy.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module V2 + # Policies for accessing Policies + class PolicyPolicy < V2::ApplicationPolicy + def index? + true # FIXME: this is handled in scoping + end + + def show? + match_account? + end + + def update? + match_account? + end + + def destroy? + match_account? + end + + # Only show hosts in our user account + class Scope < V2::ApplicationPolicy::Scope + def resolve + return scope.where('1=0') if user&.account_id.blank? + + scope.where(account_id: user.account_id) + end + end + end +end diff --git a/app/serializers/v2/policy_serializer.rb b/app/serializers/v2/policy_serializer.rb new file mode 100644 index 0000000000..a31b504ab2 --- /dev/null +++ b/app/serializers/v2/policy_serializer.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module V2 + # JSON serialization for Policies + class PolicySerializer < V2::ApplicationSerializer + attributes :title, :description, :business_objective, :compliance_threshold, :host_count + + derived_attribute :os_major_version, security_guide: [:ref_id] + derived_attribute :profile_title, profile: [:title] + derived_attribute :ref_id, profile: [:ref_id] + end +end diff --git a/config/routes.rb b/config/routes.rb index ba65f52a9a..1dd1a8a966 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -31,6 +31,8 @@ def draw_routes(prefix) resources :rules, only: [:index, :show], parents: [:security_guide, :profiles] end end + + resources :policies, except: [:new, :edit] end end diff --git a/spec/controllers/v2/policies_controller_spec.rb b/spec/controllers/v2/policies_controller_spec.rb new file mode 100644 index 0000000000..e02526fe23 --- /dev/null +++ b/spec/controllers/v2/policies_controller_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe V2::PoliciesController do + let(:attributes) do + { + title: :title, + description: :description, + business_objective: :business_objective, + compliance_threshold: :compliance_threshold, + host_count: :host_count, + ref_id: :ref_id, + profile_title: :profile_title, + os_major_version: :os_major_version + } + end + + let(:current_user) { FactoryBot.create(:v2_user) } + let(:rbac_allowed?) { true } + + before do + request.headers['X-RH-IDENTITY'] = current_user.account.identity_header.raw + allow(StrongerParameters::InvalidValue).to receive(:new) { |value, _| value.to_sym } + allow(controller).to receive(:rbac_allowed?).and_return(rbac_allowed?) + end + + describe 'GET index' do + let(:extra_params) { { account: current_user.account } } + let(:parents) { nil } + let(:item_count) { 2 } + + let(:items) do + FactoryBot.create_list( + :v2_policy, + item_count, + account: current_user.account + ).sort_by(&:id) + end + + it_behaves_like 'collection' + include_examples 'with metadata' + it_behaves_like 'paginable' + it_behaves_like 'sortable' + it_behaves_like 'searchable' + end + + describe 'GET show' do + let(:item) { FactoryBot.create(:v2_policy, account: current_user.account) } + let(:extra_params) { { id: item.id } } + + it_behaves_like 'individual' + end +end diff --git a/spec/factories/policy.rb b/spec/factories/policy.rb new file mode 100644 index 0000000000..01cabf412e --- /dev/null +++ b/spec/factories/policy.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +FactoryBot.define do + factory :v2_policy, class: 'V2::Policy' do + title { Faker::Lorem.sentence } + description { Faker::Lorem.paragraph } + profile { association :v2_profile, os_major_version: os_major_version } + compliance_threshold { SecureRandom.random_number(100) } + host_count { 0 } + + transient do + os_major_version { 7 } + end + end +end diff --git a/spec/fixtures/files/searchable/policies_controller.yaml b/spec/fixtures/files/searchable/policies_controller.yaml new file mode 100644 index 0000000000..ac3d9928f3 --- /dev/null +++ b/spec/fixtures/files/searchable/policies_controller.yaml @@ -0,0 +1,68 @@ +--- + +- :name: "equality search by title" + :entities: + :found: + - :factory: :v2_policy + :title: searched title + :account: ${account} + :not_found: + - :factory: :v2_policy + :title: not this title + :account: ${account} + :query: (title = "searched title") +- :name: "non-equality search by title" + :entities: + :found: + - :factory: :v2_policy + :title: not this title + :account: ${account} + :not_found: + - :factory: :v2_policy + :title: searched title + :account: ${account} + :query: (title != "searched title") +- :name: "in search by title" + :entities: + :found: + - :factory: :v2_policy + :title: searched title + :account: ${account} + :not_found: + - :factory: :v2_policy + :title: not this title + :account: ${account} + :query: (title ^ "searched title") +- :name: "not-in search by title" + :entities: + :found: + - :factory: :v2_policy + :title: not this title + :account: ${account} + :not_found: + - :factory: :v2_policy + :title: searched title + :account: ${account} + :query: (title !^ "searched title") +- :name: "like search by title" + :entities: + :found: + - :factory: :v2_policy + :title: searched title + :account: ${account} + :not_found: + - :factory: :v2_policy + :title: not this title + :account: ${account} + :query: (title ~ "searched title") +- :name: "unlike search by title" + :entities: + :found: + - :factory: :v2_policy + :title: not this title + :account: ${account} + :not_found: + - :factory: :v2_policy + :title: searched title + :account: ${account} + :query: (title !~ "searched title") diff --git a/spec/fixtures/files/sortable/policies_controller.yaml b/spec/fixtures/files/sortable/policies_controller.yaml new file mode 100644 index 0000000000..eafe82df14 --- /dev/null +++ b/spec/fixtures/files/sortable/policies_controller.yaml @@ -0,0 +1,65 @@ +:entities: + - :factory: :v2_policy + :title: 'aba' + :business_objective: 'aba' + :compliance_threshold: 90 + :account: ${account} + + - :factory: :v2_policy + :title: 'bac' + :business_objective: 'bac' + :compliance_threshold: 85 + :account: ${account} + + - :factory: :v2_policy + :title: 'aab' + :business_objective: 'aab' + :compliance_threshold: 90 + :account: ${account} + + - :factory: :v2_policy + :title: 'aaa' + :business_objective: 'aaa' + :compliance_threshold: 85 + :account: ${account} + + - :factory: :v2_policy + :title: 'caa' + :business_objective: 'caa' + :compliance_threshold: 100 + :account: ${account} + + - :factory: :v2_policy + :title: 'aaa' + :business_objective: 'aaa' + :compliance_threshold: 80 + :account: ${account} + +:queries: + - :sort_by: + - 'title' + :result: [[3, 5], 2, 0, 1, 4] + - :sort_by: + - 'title:asc' + :result: [[3, 5], 2, 0, 1, 4] + - :sort_by: + - 'title:desc' + :result: [4, 1, 0, 2, [3, 5]] + - :sort_by: + - 'business_objective' + :result: [[3, 5], 2, 0, 1, 4] + - :sort_by: + - 'business_objective:asc' + :result: [[3, 5], 2, 0, 1, 4] + - :sort_by: + - 'business_objective:desc' + :result: [4, 1, 0, 2, [3, 5]] + - :sort_by: + - 'compliance_threshold' + :result: [5, [3, 1], [0, 2], 4] + - :sort_by: + - 'compliance_threshold:asc' + :result: [5, [3, 1], [0, 2], 4] + - :sort_by: + - 'compliance_threshold:desc' + :result: [4, [0, 2], [3, 1], 5] diff --git a/spec/models/v2/policy_spec.rb b/spec/models/v2/policy_spec.rb new file mode 100644 index 0000000000..923b8c4c2f --- /dev/null +++ b/spec/models/v2/policy_spec.rb @@ -0,0 +1,99 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe V2::Policy do + let(:account) { FactoryBot.create(:account) } + + describe '#os_major_version' do + let(:policy) { FactoryBot.create(:v2_policy, os_major_version: os_major_version, account: account) } + + subject { policy } + + [7, 8, 9].each do |os| + context "os_major_version is #{os}" do + let(:os_major_version) { os } + + it 'returns with the correct version' do + expect(subject.os_major_version).to eq(os) + end + end + end + + context 'with autojoin' do + subject do + V2::Policy.where.associated(:security_guide).select( + described_class.arel_table[Arel.star], + 'security_guide.ref_id AS security_guide__ref_id', + ).find(policy.id) + end + + # Mock the security_guide method to fail the test if autojoin doesn't work + before { allow(subject).to receive(:security_guide).and_return(nil) } + + [7, 8, 9].each do |os| + context "os_major_version is #{os}" do + let(:os_major_version) { os } + + it 'returns with the correct version' do + expect(subject.os_major_version).to eq(os) + end + end + end + end + end + + describe '#profile_title' do + let(:profile) { FactoryBot.create(:v2_profile) } + let(:policy) { FactoryBot.create(:v2_policy, account: account, profile: profile) } + + subject { policy } + + it 'returns with the correct title' do + expect(subject.profile_title).to eq(profile.title) + end + + context 'with autojoin' do + subject do + V2::Policy.where.associated(:profile).select( + described_class.arel_table[Arel.star], + 'profile.title AS profile__title', + ).find(policy.id) + end + + # Mock the profile method to fail the test if autojoin doesn't work + before { allow(subject).to receive(:profile).and_return(nil) } + + it 'returns with the correct title' do + expect(subject.profile_title).to eq(profile.title) + end + end + end + + describe '#ref_id' do + let(:profile) { FactoryBot.create(:v2_profile) } + let(:policy) { FactoryBot.create(:v2_policy, account: account, profile: profile) } + + subject { policy } + + it 'returns with the correct ref_id' do + expect(subject.ref_id).to eq(profile.ref_id) + end + + context 'with autojoin' do + subject do + V2::Policy.where.associated(:profile).select( + described_class.arel_table[Arel.star], + 'profile.ref_id AS profile__ref_id', + ).find(policy.id) + end + + # Mock the profile method to fail the test if autojoin doesn't work + before { allow(subject).to receive(:profile).and_return(nil) } + + it 'returns with the correct ref_id' do + expect(subject.ref_id).to eq(profile.ref_id) + end + end + end +end diff --git a/spec/policies/v2/policy_policy_spec.rb b/spec/policies/v2/policy_policy_spec.rb new file mode 100644 index 0000000000..02537bfc02 --- /dev/null +++ b/spec/policies/v2/policy_policy_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'rails_helper' + +describe V2::PolicyPolicy do + let(:user) { FactoryBot.create(:user) } + let!(:items) { FactoryBot.create_list(:v2_policy, 20, account: user.account) } + + before { FactoryBot.create_list(:v2_policy, 10, account: FactoryBot.create(:account)) } + + it 'allows displaying entities related to current user' do + expect(Pundit.policy_scope(user, V2::Policy).to_set).to eq(items.to_set) + end + + it 'authorizes the index and show actions' do + items.each do |item| + expect(Pundit.authorize(user, item, :show?)).to be_truthy + expect(Pundit.authorize(user, item, :update?)).to be_truthy + expect(Pundit.authorize(user, item, :destroy?)).to be_truthy + end + + V2::Policy.where.not(id: items.map(&:id)).each do |item| + expect { Pundit.authorize(user, item, :show?) }.to raise_error(Pundit::NotAuthorizedError) + expect { Pundit.authorize(user, item, :update?) }.to raise_error(Pundit::NotAuthorizedError) + expect { Pundit.authorize(user, item, :destroy?) }.to raise_error(Pundit::NotAuthorizedError) + end + end +end