diff --git a/ee/app/graphql/resolvers/security_training_urls_resolver.rb b/ee/app/graphql/resolvers/security_training_urls_resolver.rb
index 30a7438fed2c26c0d3f649a83a2edb28906d86c9..f4141d4f312100112efd9b0e6a442451ec89f5d8 100644
--- a/ee/app/graphql/resolvers/security_training_urls_resolver.rb
+++ b/ee/app/graphql/resolvers/security_training_urls_resolver.rb
@@ -6,7 +6,7 @@ class SecurityTrainingUrlsResolver < BaseResolver
 
     type [::Types::Security::TrainingUrlType], null: true
 
-    authorize :access_security_and_compliance
+    authorize :read_security_resource
     authorizes_object!
 
     argument :identifier_external_ids,
diff --git a/ee/app/models/ee/user.rb b/ee/app/models/ee/user.rb
index 7fde47c6dc9fe69f2a418124cde42779417b8678..bccda661fb559796cfea64ac4691fb983b146712 100644
--- a/ee/app/models/ee/user.rb
+++ b/ee/app/models/ee/user.rb
@@ -516,6 +516,13 @@ def read_code_for?(project)
       roles&.include?(:read_code)
     end
 
+    def read_vulnerability_for?(project)
+      return false unless ::Feature.enabled?(:custom_roles_vulnerability, project.root_ancestor)
+
+      roles = preloaded_member_roles_for_projects([project])[project.id]
+      roles&.include?(:read_vulnerability)
+    end
+
     override :preloaded_member_roles_for_projects
     def preloaded_member_roles_for_projects(projects)
       resource_key = "member_roles_in_projects:#{self.class}:#{self.id}"
diff --git a/ee/app/models/members/member_role.rb b/ee/app/models/members/member_role.rb
index 0927da80e574f1adb24c4d2551bced0720bb3fb7..f2fcb40310eb537e9547c3214dc24399a43f7b5e 100644
--- a/ee/app/models/members/member_role.rb
+++ b/ee/app/models/members/member_role.rb
@@ -5,7 +5,12 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass
   ignore_column :download_code, remove_with: '15.9', remove_after: '2023-01-22'
 
   MAX_COUNT_PER_GROUP_HIERARCHY = 10
-  ALL_CUSTOMIZABLE_PERMISSIONS = { read_code: 'Permission to read code' }.freeze
+  ALL_CUSTOMIZABLE_PERMISSIONS = {
+    read_code:
+      { description: 'Permission to read code', minimal_level: Gitlab::Access::GUEST },
+    read_vulnerability:
+      { descripition: 'Permission to read vulnerability', minimal_level: Gitlab::Access::GUEST }
+  }.freeze
   CUSTOMIZABLE_PERMISSIONS_EXEMPT_FROM_CONSUMING_SEAT = [:read_code].freeze
   NON_PERMISSION_COLUMNS = [:id, :namespace_id, :created_at, :updated_at, :base_access_level].freeze
 
@@ -18,6 +23,9 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass
   validate :max_count_per_group_hierarchy, on: :create
   validate :validate_namespace_locked, on: :update
   validate :attributes_locked_after_member_associated, on: :update
+  validate :validate_minimal_base_access_level, if: ->(member_role) do
+    Feature.enabled?(:custom_roles_vulnerability, member_role.namespace&.root_ancestor)
+  end
 
   validates_associated :members
 
@@ -64,6 +72,19 @@ def validate_namespace_locked
     errors.add(:namespace, s_("MemberRole|can't be changed"))
   end
 
+  def validate_minimal_base_access_level
+    ALL_CUSTOMIZABLE_PERMISSIONS.each do |permission, params|
+      min_level = params[:minimal_level]
+      next unless self[permission]
+      next if base_access_level >= min_level
+
+      errors.add(:base_access_level,
+        format(s_("MemberRole|minimal base access level must be %{min_access_level}."),
+          min_access_level: "#{Gitlab::Access.options_with_owner.key(min_level)} (#{min_level})")
+      )
+    end
+  end
+
   def attributes_locked_after_member_associated
     return unless members.present?
 
diff --git a/ee/app/models/preloaders/user_member_roles_in_projects_preloader.rb b/ee/app/models/preloaders/user_member_roles_in_projects_preloader.rb
index 96c81198ce003044796335f50ef028f65560b5c9..8d8f212cc59df65d52535278dad02c9169444e1a 100644
--- a/ee/app/models/preloaders/user_member_roles_in_projects_preloader.rb
+++ b/ee/app/models/preloaders/user_member_roles_in_projects_preloader.rb
@@ -26,29 +26,31 @@ def execute
       value_list = Arel::Nodes::ValuesList.new(sql_values_array)
 
       sql = <<~SQL
-        SELECT project_ids.project_id, custom_permissions.read_code FROM (#{value_list.to_sql}) AS project_ids (project_id, namespace_ids),
+      SELECT project_ids.project_id, bool_or(custom_permissions.read_code) as read_code, bool_or(custom_permissions.read_vulnerability) as read_vulnerability
+        FROM (#{value_list.to_sql}) AS project_ids (project_id, namespace_ids),
         LATERAL (
           (
-           #{Member.select('read_code')
+           #{Member.select('read_code, read_vulnerability')
               .left_outer_joins(:member_role)
               .where("members.source_type = 'Project' AND members.source_id = project_ids.project_id")
               .with_user(user)
-              .where(member_roles: { read_code: true })
+              .where('member_roles.read_code = true OR member_roles.read_vulnerability = true')
               .limit(1).to_sql}
           ) UNION ALL
           (
-           #{Member.select('read_code')
+           #{Member.select('read_code, read_vulnerability')
               .left_outer_joins(:member_role)
               .where("members.source_type = 'Namespace' AND members.source_id IN (SELECT UNNEST(project_ids.namespace_ids) as ids)")
               .with_user(user)
-              .where(member_roles: { read_code: true })
+              .where('member_roles.read_code = true OR member_roles.read_vulnerability = true')
               .limit(1).to_sql}
           ) UNION ALL
           (
-            SELECT false AS read_code
+            SELECT false AS read_code, false AS read_vulnerability
           )
           LIMIT 1
         ) AS custom_permissions
+        GROUP BY project_ids.project_id;
       SQL
 
       grouped_by_project = ApplicationRecord.connection.execute(sql).to_a.group_by do |h|
@@ -58,6 +60,11 @@ def execute
       grouped_by_project.transform_values do |value|
         custom_attributes = []
         custom_attributes << :read_code if value.find { |custom_role| custom_role["read_code"] == true }
+
+        if value.find { |custom_role| custom_role["read_vulnerability"] == true }
+          custom_attributes << :read_vulnerability
+        end
+
         custom_attributes
       end
     end
diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb
index 4cfdc37b6c38b45de4c93525042bd93dc215b774..e66e624483eb800b44ebc2feed4660c5499dc9fb 100644
--- a/ee/app/policies/ee/project_policy.rb
+++ b/ee/app/policies/ee/project_policy.rb
@@ -207,6 +207,10 @@ module ProjectPolicy
         @subject.custom_roles_enabled?
       end
 
+      condition(:custom_roles_vulnerabilities_allowed) do
+        ::Feature.enabled?(:custom_roles_vulnerability, @subject.root_ancestor)
+      end
+
       desc "Custom role on project that enables read code"
       condition(:role_enables_read_code) do
         next unless @user.is_a?(User)
@@ -214,6 +218,13 @@ module ProjectPolicy
         @user.read_code_for?(project)
       end
 
+      desc "Custom role on project that enables read vulnerability"
+      condition(:role_enables_read_vulnerability) do
+        next unless @user.is_a?(User)
+
+        @user.read_vulnerability_for?(project)
+      end
+
       with_scope :subject
       condition(:suggested_reviewers_available) do
         @subject.can_suggest_reviewers?
@@ -546,6 +557,11 @@ module ProjectPolicy
       end
 
       rule { custom_roles_allowed & role_enables_read_code }.enable :read_code
+      rule { custom_roles_allowed & custom_roles_vulnerabilities_allowed & role_enables_read_vulnerability }.policy do
+        enable :read_vulnerability
+        enable :read_security_resource
+        enable :create_vulnerability_export
+      end
 
       rule { can?(:create_issue) & okrs_enabled }.policy do
         enable :create_objective
diff --git a/ee/config/feature_flags/development/custom_roles_vulnerability.yml b/ee/config/feature_flags/development/custom_roles_vulnerability.yml
new file mode 100644
index 0000000000000000000000000000000000000000..c6a3317a26e4f6b44805cb7c02e046e6afa8801f
--- /dev/null
+++ b/ee/config/feature_flags/development/custom_roles_vulnerability.yml
@@ -0,0 +1,8 @@
+---
+name: custom_roles_vulnerability
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/114734
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/409387
+milestone: '16.0'
+type: development
+group: group::authentication and authorization
+default_enabled: false
diff --git a/ee/lib/api/member_roles.rb b/ee/lib/api/member_roles.rb
index e5af5463ae0bf88b34546d61fbc9bee7306672d7..512dd61f9c652c937948d2897b64e2f76be5479f 100644
--- a/ee/lib/api/member_roles.rb
+++ b/ee/lib/api/member_roles.rb
@@ -42,8 +42,8 @@ class MemberRoles < ::API::Base
           documentation: { example: 10 }
         )
 
-        ::MemberRole::ALL_CUSTOMIZABLE_PERMISSIONS.each do |permission_name, permission_description|
-          optional permission_name.to_s, type: Boolean, desc: permission_description, default: false
+        ::MemberRole::ALL_CUSTOMIZABLE_PERMISSIONS.each do |permission_name, permission_params|
+          optional permission_name.to_s, type: Boolean, desc: permission_params[:description], default: false
         end
       end
 
diff --git a/ee/spec/factories/member_roles.rb b/ee/spec/factories/member_roles.rb
index 503438d25211939d229ed03c1bb59d827591c40a..1c6f9806ee902cb0d23a3c3ba84c8e8898237e4a 100644
--- a/ee/spec/factories/member_roles.rb
+++ b/ee/spec/factories/member_roles.rb
@@ -6,6 +6,7 @@
     base_access_level { Gitlab::Access::DEVELOPER }
 
     trait(:developer) { base_access_level { Gitlab::Access::DEVELOPER } }
+    trait(:reporter) { base_access_level { Gitlab::Access::REPORTER } }
     trait(:guest) { base_access_level { Gitlab::Access::GUEST } }
   end
 end
diff --git a/ee/spec/graphql/resolvers/security_training_urls_resolver_spec.rb b/ee/spec/graphql/resolvers/security_training_urls_resolver_spec.rb
index 6e8866429f1bdd3201ab924063aaa5bbfb8f0953..31454b579635e244799747d58b306ee7c6799c52 100644
--- a/ee/spec/graphql/resolvers/security_training_urls_resolver_spec.rb
+++ b/ee/spec/graphql/resolvers/security_training_urls_resolver_spec.rb
@@ -6,6 +6,10 @@
   include GraphqlHelpers
 
   describe '#resolve' do
+    before do
+      stub_licensed_features(security_dashboard: true)
+    end
+
     subject { resolve(described_class, obj: project, ctx: { current_user: user }) }
 
     let_it_be(:user) { create(:user) }
diff --git a/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb b/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb
index 01ee1440cb63609aa274574f2185a33a4b2f839f..18cfe5f97c95527ff3472542012e6035355d60ec 100644
--- a/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb
+++ b/ee/spec/graphql/resolvers/vulnerability_severities_count_resolver_spec.rb
@@ -8,7 +8,7 @@
   describe '#resolve' do
     subject { resolve(described_class, obj: vulnerable, args: filters, ctx: { current_user: current_user }) }
 
-    let_it_be(:project) { create(:project) }
+    let_it_be(:project) { create(:project, :security_and_compliance_enabled) }
     let_it_be(:cluster_agent) { create(:cluster_agent, project: project) }
     let_it_be(:user) { create(:user, security_dashboard_projects: [project]) }
 
diff --git a/ee/spec/lib/ee/api/entities/member_role_spec.rb b/ee/spec/lib/ee/api/entities/member_role_spec.rb
index 9eba9e7ce204a53c5d464603300c41d5849ac4c5..87bae88d76595054443c53b6d3a91e2d6fb9aabc 100644
--- a/ee/spec/lib/ee/api/entities/member_role_spec.rb
+++ b/ee/spec/lib/ee/api/entities/member_role_spec.rb
@@ -16,6 +16,7 @@
       expect(subject[:id]).to eq member_role.id
       expect(subject[:base_access_level]).to eq member_role.base_access_level
       expect(subject[:read_code]).to eq member_role.read_code
+      expect(subject[:read_vulnerability]).to eq member_role.read_vulnerability
       expect(subject[:group_id]).to eq(member_role.namespace.id)
     end
   end
diff --git a/ee/spec/models/ee/user_spec.rb b/ee/spec/models/ee/user_spec.rb
index 7d50825acb921fe5048fb4dc3e503cfaeb900197..7b857dcb81e1ba658e37e72aebc43951eafb35f6 100644
--- a/ee/spec/models/ee/user_spec.rb
+++ b/ee/spec/models/ee/user_spec.rb
@@ -2129,6 +2129,69 @@
     end
   end
 
+  describe '#read_vulnerability_for?', :request_store do
+    let_it_be(:project) { create(:project, :in_group) }
+    let_it_be(:user) { create(:user) }
+    let_it_be(:another_user) { create(:user) }
+
+    before do
+      stub_licensed_features(custom_roles: true)
+    end
+
+    before_all do
+      project_member = create(:project_member, :reporter, user: user, source: project)
+      create(
+        :member_role,
+        :reporter,
+        read_vulnerability: true,
+        members: [project_member],
+        namespace: project.group
+      )
+    end
+
+    context 'with custom_roles_vulnerability FF enabled' do
+      before do
+        stub_feature_flags(custom_roles_vulnerability: [project.group])
+      end
+
+      context 'when read_vulnerability present in preloaded custom roles' do
+        before do
+          user.read_vulnerability_for?(project)
+        end
+
+        it 'returns true' do
+          expect(user.read_vulnerability_for?(project)).to be true
+        end
+
+        it 'does not perform extra queries when asked for projects have already been preloaded' do
+          expect { user.read_vulnerability_for?(project) }.not_to exceed_query_limit(0)
+        end
+      end
+
+      context 'when project not present in preloaded custom roles' do
+        it 'loads the custom role' do
+          expect(user.read_vulnerability_for?(project)).to be true
+        end
+      end
+
+      context 'when a user is not assigned to the custom role' do
+        it 'returns false' do
+          expect(another_user.read_vulnerability_for?(project)).to be false
+        end
+      end
+    end
+
+    context 'with custom_roles_vulnerability FF disabled' do
+      before do
+        stub_feature_flags(custom_roles_vulnerability: false)
+      end
+
+      it 'returns false' do
+        expect(user.read_vulnerability_for?(project)).to be false
+      end
+    end
+  end
+
   describe '#can_group_owner_disable_two_factor?' do
     let_it_be(:group) { create(:group) }
     let_it_be(:owner) { create(:user) }
diff --git a/ee/spec/models/members/member_role_spec.rb b/ee/spec/models/members/member_role_spec.rb
index 77e0a1542ff8694f35d7581d15452442166b5722..e28df6c1bfb8a73ef0d4a2f694e3a3feecaa0024 100644
--- a/ee/spec/models/members/member_role_spec.rb
+++ b/ee/spec/models/members/member_role_spec.rb
@@ -100,6 +100,32 @@
           expect(member_role.errors[:namespace]).to include(s_("MemberRole|can't be changed"))
         end
       end
+
+      context 'when base_access_level is too low' do
+        context 'when custom_roles_vulnerability FF is enabled' do
+          it 'creates a validation error' do
+            member_role.base_access_level = Gitlab::Access::MINIMAL_ACCESS
+            member_role.read_vulnerability = true
+
+            expect(member_role).not_to be_valid
+            expect(member_role.errors[:base_access_level])
+              .to include(s_("MemberRole|minimal base access level must be Guest (10)."))
+          end
+        end
+
+        context 'when custom_roles_vulnerability FF is disabled' do
+          before do
+            stub_feature_flags(custom_roles_vulnerability: false)
+          end
+
+          it 'is valid' do
+            member_role.base_access_level = Gitlab::Access::MINIMAL_ACCESS
+            member_role.read_vulnerability = true
+
+            expect(member_role).to be_valid
+          end
+        end
+      end
     end
   end
 
diff --git a/ee/spec/models/preloaders/user_member_roles_in_projects_preloader_spec.rb b/ee/spec/models/preloaders/user_member_roles_in_projects_preloader_spec.rb
index e9b5a44071ee4bb8e30d8d34f78f067613774a0b..0db84fb0101a3d49846c47c4b22649a81a7e8d4f 100644
--- a/ee/spec/models/preloaders/user_member_roles_in_projects_preloader_spec.rb
+++ b/ee/spec/models/preloaders/user_member_roles_in_projects_preloader_spec.rb
@@ -11,108 +11,130 @@
 
   subject(:result) { described_class.new(projects: project_list, user: user).execute }
 
-  context 'when custom_roles license is not enabled on project root ancestor' do
-    it 'skips preload' do
-      stub_licensed_features(custom_roles: false)
-      create(:member_role, :guest, read_code: true, members: [project_member], namespace: project.group)
-
-      expect(result).to eq({})
-    end
-  end
+  shared_examples 'custom roles' do |ability|
+    context 'when custom_roles license is not enabled on project root ancestor' do
+      it 'skips preload' do
+        stub_licensed_features(custom_roles: false)
+        create(:member_role, :guest, namespace: project.group).tap do |record|
+          record[ability] = true
+          record.save!
+          record.members << project_member
+        end
 
-  context 'when custom_roles license is enabled on project root ancestor' do
-    before do
-      stub_licensed_features(custom_roles: true)
+        expect(result).to eq({})
+      end
     end
 
-    context 'when project has custom role' do
-      let_it_be(:member_role) do
-        create(:member_role, :guest, members: [project_member], namespace: project.group, read_code: true)
+    context 'when custom_roles license is enabled on project root ancestor' do
+      before do
+        stub_licensed_features(custom_roles: true)
       end
 
-      context 'when custom role has read_code: true' do
-        context 'when Array of project passed' do
-          it 'returns the project_id with a value array that includes :read_code' do
-            expect(result).to eq({ project.id => [:read_code] })
+      context 'when project has custom role' do
+        let_it_be(:member_role) do
+          create(:member_role, :guest, namespace: project.group).tap do |record|
+            record[ability] = true
+            record.save!
+            record.members << project_member
           end
         end
 
-        context 'when ActiveRecord::Relation of projects passed' do
-          let(:project_list) { Project.where(id: project.id) }
+        context 'when custom role has ability: true' do
+          context 'when Array of project passed' do
+            it 'returns the project_id with a value array that includes the ability' do
+              expect(result).to eq({ project.id => [ability] })
+            end
+          end
+
+          context 'when ActiveRecord::Relation of projects passed' do
+            let(:project_list) { Project.where(id: project.id) }
 
-          it 'returns the project_id with a value array that includes :read_code' do
-            expect(result).to eq({ project.id => [:read_code] })
+            it 'returns the project_id with a value array that includes the ability' do
+              expect(result).to eq({ project.id => [ability] })
+            end
           end
         end
       end
-    end
 
-    context 'when project namespace has a custom role with read_code: true' do
-      let_it_be(:group_member) { create(:group_member, :guest, user: user, source: project.namespace) }
-      let_it_be(:member_role) do
-        create(:member_role, :guest, read_code: true, members: [group_member], namespace: project.group)
-      end
+      context 'when project namespace has a custom role with ability: true' do
+        let_it_be(:group_member) { create(:group_member, :guest, user: user, source: project.namespace) }
+        let_it_be(:member_role) do
+          create(:member_role, :guest, namespace: project.group).tap do |record|
+            record[ability] = true
+            record.save!
+            record.members << group_member
+          end
+        end
 
-      it 'returns the project_id with a value array that includes :read_code' do
-        expect(result).to eq({ project.id => [:read_code] })
+        it 'returns the project_id with a value array that includes the ability' do
+          expect(result).to eq({ project.id => [ability] })
+        end
       end
-    end
 
-    context 'when user is a member of the project in multiple ways' do
-      let_it_be(:group_member) { create(:group_member, :guest, user: user, source: project.group) }
+      context 'when user is a member of the project in multiple ways' do
+        let_it_be(:group_member) { create(:group_member, :guest, user: user, source: project.group) }
 
-      it 'project value array includes :read_code if any custom roles enable them' do
-        create(:member_role, :guest, read_code: false, members: [project_member], namespace: project.group)
-        create(:member_role, :guest, read_code: true, members: [project_member], namespace: project.group)
+        it 'project value array includes the ability' do
+          create(:member_role, :guest, namespace: project.group).tap do |record|
+            record[ability] = false
+            record.save!
+            record.members << project_member
+          end
+          create(:member_role, :guest, namespace: project.group).tap do |record|
+            record[ability] = true
+            record.save!
+            record.members << project_member
+          end
 
-        expect(result[project.id]).to match_array([:read_code])
+          expect(result[project.id]).to match_array([ability])
+        end
       end
-    end
 
-    context 'when project membership has no custom role' do
-      let_it_be(:project) { create(:project, :private, :in_group) }
+      context 'when project membership has no custom role' do
+        let_it_be(:project) { create(:project, :private, :in_group) }
 
-      it 'returns project id with empty value array' do
-        expect(result).to eq(project.id => [])
+        it 'returns project id with empty value array' do
+          expect(result).to eq(project.id => [])
+        end
       end
-    end
 
-    context 'when project membership has custom role that does not enable custom permission' do
-      let_it_be(:project) { create(:project, :private, :in_group) }
-
-      it 'returns project id with empty value array' do
-        project_without_custom_permission_member = create(
-          :project_member,
-          :guest,
-          user: user,
-          source: project
-        )
-        create(
-          :member_role,
-          :guest,
-          read_code: false,
-          members: [project_without_custom_permission_member],
-          namespace: project.group
-        )
-
-        expect(result).to eq(project.id => [])
+      context 'when project membership has custom role that does not enable custom permission' do
+        let_it_be(:project) { create(:project, :private, :in_group) }
+
+        it 'returns project id with empty value array' do
+          project_without_custom_permission_member = create(
+            :project_member,
+            :guest,
+            user: user,
+            source: project
+          )
+          create(:member_role, :guest, namespace: project.group).tap do |record|
+            record[ability] = false
+            record.save!
+            record.members << project_without_custom_permission_member
+          end
+
+          expect(result).to eq(project.id => [])
+        end
       end
-    end
 
-    context 'when user has custom role that enables custom permission outside of project hierarchy' do
-      it 'ignores custom role outside of project hierarchy' do
-        # subgroup is within parent group of project but not above project
-        subgroup = create(:group, parent: project.group)
-        subgroup_member = create(:group_member, :guest, user: user, source: subgroup)
-        _custom_role_outside_hierarchy = create(
-          :member_role, :guest,
-          members: [subgroup_member],
-          read_code: true,
-          namespace: project.group
-        )
-
-        expect(result).to eq({ project.id => [] })
+      context 'when user has custom role that enables custom permission outside of project hierarchy' do
+        it 'ignores custom role outside of project hierarchy' do
+          # subgroup is within parent group of project but not above project
+          subgroup = create(:group, parent: project.group)
+          subgroup_member = create(:group_member, :guest, user: user, source: subgroup)
+          _custom_role_outside_hierarchy = create(:member_role, :guest, namespace: project.group).tap do |record|
+            record[ability] = false
+            record.save!
+            record.members << subgroup_member
+          end
+
+          expect(result).to eq({ project.id => [] })
+        end
       end
     end
   end
+
+  it_behaves_like 'custom roles', :read_code
+  it_behaves_like 'custom roles', :read_vulnerability
 end
diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb
index 50dff6a92314e279b5b654d0d9a8a4adea09a206..aa84e55c303ac1885d733ea3754b523e4424d891 100644
--- a/ee/spec/policies/project_policy_spec.rb
+++ b/ee/spec/policies/project_policy_spec.rb
@@ -2435,27 +2435,51 @@ def expect_private_project_permissions_as_if_non_member
   end
 
   context 'custom role' do
-    let_it_be(:current_user) { create(:user) }
+    def custom_role_allowed?(user, ability)
+      described_class.new(user, project).allowed?(ability)
+    end
+
+    let_it_be(:guest) { create(:user) }
+    let_it_be(:reporter) { create(:user) }
     let_it_be(:project) { private_project_in_group }
-    let_it_be(:group_member) do
+    let_it_be(:group_member_guest) do
       create(
         :group_member,
-        user: current_user,
+        user: guest,
         source: project.group,
         access_level: Gitlab::Access::GUEST
       )
     end
 
-    let_it_be(:project_member) do
+    let_it_be(:group_member_reporter) do
+      create(
+        :group_member,
+        user: reporter,
+        source: project.group,
+        access_level: Gitlab::Access::REPORTER
+      )
+    end
+
+    let_it_be(:project_member_guest) do
       create(
         :project_member,
         :guest,
-        user: current_user,
+        user: guest,
         project: project,
         access_level: Gitlab::Access::GUEST
       )
     end
 
+    let_it_be(:project_member_reporter) do
+      create(
+        :project_member,
+        :guest,
+        user: reporter,
+        project: project,
+        access_level: Gitlab::Access::REPORTER
+      )
+    end
+
     let_it_be(:member_role_read_code_true) do
       create(
         :member_role,
@@ -2474,62 +2498,147 @@ def expect_private_project_permissions_as_if_non_member
       )
     end
 
+    let_it_be(:member_role_read_vulnerability_true) do
+      create(
+        :member_role,
+        :reporter,
+        namespace: project.group,
+        read_vulnerability: true
+      )
+    end
+
+    let_it_be(:member_role_read_vulnerability_false) do
+      create(
+        :member_role,
+        :reporter,
+        namespace: project.group,
+        read_vulnerability: false
+      )
+    end
+
     context 'custom_roles license enabled' do
       before do
         stub_licensed_features(custom_roles: true)
       end
 
       context 'custom role for parent group' do
-        context 'custom role allows read code' do
-          before do
-            member_role_read_code_true.members << group_member
+        context 'custom role allows abilities' do
+          it 'allows guest to read code' do
+            member_role_read_code_true.members << group_member_guest
+
+            expect(custom_role_allowed?(guest, :read_code)).to be_truthy
           end
 
-          it { is_expected.to be_allowed(:read_code) }
+          it 'allows reporter to read security related resources' do
+            member_role_read_vulnerability_true.members << group_member_reporter
+
+            expect(custom_role_allowed?(reporter, :read_vulnerability)).to be_truthy
+            expect(custom_role_allowed?(reporter, :read_security_resource)).to be_truthy
+            expect(custom_role_allowed?(reporter, :create_vulnerability_export)).to be_truthy
+          end
         end
 
-        context 'custom role disallows read code' do
-          before do
-            member_role_read_code_false.members << group_member
+        context 'custom role disallows abilities' do
+          it 'does not allow guest to read code' do
+            member_role_read_code_false.members << group_member_guest
+
+            expect(custom_role_allowed?(guest, :read_code)).to be_falsey
           end
 
-          it { is_expected.to be_disallowed(:read_code) }
+          it 'does not allow reporter to read security related resources' do
+            member_role_read_vulnerability_false.members << group_member_reporter
+
+            expect(custom_role_allowed?(reporter, :read_vulnerability)).to be_falsey
+            expect(custom_role_allowed?(reporter, :read_security_resource)).to be_falsey
+            expect(custom_role_allowed?(reporter, :create_vulnerability_export)).to be_falsey
+          end
         end
       end
 
       context 'custom role on project membership' do
-        context 'custom role allows read code' do
-          before do
-            member_role_read_code_true.members << project_member
+        context 'custom role allows abilities' do
+          it 'allows guest to read code' do
+            member_role_read_code_true.members << project_member_guest
+
+            expect(custom_role_allowed?(guest, :read_code)).to be_truthy
+          end
+
+          context 'when custom_roles_vulnerability FF is enabled' do
+            before do
+              stub_feature_flags(custom_roles_vulnerability: [project.group])
+            end
+
+            it 'allows reporter to read security related resources' do
+              member_role_read_vulnerability_true.members << project_member_reporter
+
+              expect(custom_role_allowed?(reporter, :read_vulnerability)).to be_truthy
+              expect(custom_role_allowed?(reporter, :read_security_resource)).to be_truthy
+              expect(custom_role_allowed?(reporter, :create_vulnerability_export)).to be_truthy
+            end
           end
 
-          it { is_expected.to be_allowed(:read_code) }
+          context 'when custom_roles_vulnerability FF is disabled' do
+            it 'does not allow reporter to read security related resources' do
+              member_role_read_vulnerability_true.members << project_member_reporter
+
+              expect(custom_role_allowed?(reporter, :read_vulnerability)).to be_falsey
+              expect(custom_role_allowed?(reporter, :read_security_resource)).to be_falsey
+              expect(custom_role_allowed?(reporter, :create_vulnerability_export)).to be_falsey
+            end
+          end
         end
 
-        context 'custom role disallows read code' do
-          before do
-            member_role_read_code_false.members << project_member
+        context 'custom role disallows abilities' do
+          it 'does not allow guest to read code' do
+            member_role_read_code_false.members << project_member_guest
+
+            expect(custom_role_allowed?(guest, :read_code)).to be_falsey
           end
 
-          it { is_expected.to be_disallowed(:read_code) }
+          it 'does not allow reporter to read security related resources' do
+            member_role_read_vulnerability_false.members << project_member_reporter
+
+            expect(custom_role_allowed?(reporter, :read_vulnerability)).to be_falsey
+            expect(custom_role_allowed?(reporter, :read_security_resource)).to be_falsey
+            expect(custom_role_allowed?(reporter, :create_vulnerability_export)).to be_falsey
+          end
         end
       end
 
       context 'multiple custom roles in hierarchy with different read_code values' do
+        let(:current_user) { guest }
+
         before do
-          member_role_read_code_true.members << project_member
-          member_role_read_code_false.members << group_member
+          member_role_read_code_true.members << project_member_guest
+          member_role_read_code_false.members << group_member_guest
         end
 
         # allows read code if any of the custom roles allow it
         it { is_expected.to be_allowed(:read_code) }
       end
+
+      context 'multiple custom roles in hierarchy with different read_vulnerability values' do
+        let(:current_user) { reporter }
+
+        before do
+          member_role_read_vulnerability_true.members << project_member_reporter
+          member_role_read_vulnerability_false.members << group_member_reporter
+        end
+
+        it 'allows reporter to read security related resources' do
+          expect(custom_role_allowed?(reporter, :read_vulnerability)).to be_truthy
+          expect(custom_role_allowed?(reporter, :read_security_resource)).to be_truthy
+          expect(custom_role_allowed?(reporter, :create_vulnerability_export)).to be_truthy
+        end
+      end
     end
 
     context 'without custom_roles license enabled' do
+      let(:current_user) { guest }
+
       before do
         stub_licensed_features(custom_roles: false)
-        member_role_read_code_true.members << project_member
+        member_role_read_code_true.members << project_member_guest
       end
 
       it { is_expected.to be_disallowed(:read_code) }
diff --git a/ee/spec/requests/api/member_roles_spec.rb b/ee/spec/requests/api/member_roles_spec.rb
index 5cff4002c3b8b599730a2eb3ee0432cf009cda79..bb255c8a8eb640331deed8181f4b04f5d284f938 100644
--- a/ee/spec/requests/api/member_roles_spec.rb
+++ b/ee/spec/requests/api/member_roles_spec.rb
@@ -22,7 +22,8 @@
       :member_role,
       namespace: group_with_member_roles,
       base_access_level: ::Gitlab::Access::REPORTER,
-      read_code: false
+      read_code: false,
+      read_vulnerability: true
     )
   end
 
@@ -31,7 +32,8 @@
       :member_role,
       namespace: group_with_member_roles,
       base_access_level: ::Gitlab::Access::REPORTER,
-      read_code: true
+      read_code: true,
+      read_vulnerability: false
     )
   end
 
@@ -93,12 +95,14 @@
                   "id" => member_role_1.id,
                   "base_access_level" => ::Gitlab::Access::REPORTER,
                   "read_code" => false,
+                  "read_vulnerability" => true,
                   "group_id" => group_id
                 },
                 {
                   "id" => member_role_2.id,
                   "base_access_level" => ::Gitlab::Access::REPORTER,
                   "read_code" => true,
+                  "read_vulnerability" => false,
                   "group_id" => group_id
                 }
               ]
@@ -129,7 +133,7 @@
 
   describe "POST /groups/:id/member_roles" do
     let_it_be(:params) do
-      { base_access_level: ::Gitlab::Access::MAINTAINER, read_code: true }
+      { base_access_level: ::Gitlab::Access::GUEST, read_code: true }
     end
 
     subject { post api("/groups/#{group_id}/member_roles", current_user), params: params }
@@ -171,7 +175,7 @@
 
           aggregate_failures "testing response" do
             expect(response).to have_gitlab_http_status(:created)
-            expect(json_response['base_access_level']).to eq(::Gitlab::Access::MAINTAINER)
+            expect(json_response['base_access_level']).to eq(::Gitlab::Access::GUEST)
             expect(json_response['read_code']).to eq(true)
           end
         end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index d4fd08e6267b5bd16db996fff035e635982c9117..e445147d38f821b9d76e2357168ffec336819d24 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -27716,6 +27716,9 @@ msgstr ""
 msgid "MemberRole|maximum number of Member Roles are already in use by the group hierarchy. Please delete an existing Member Role."
 msgstr ""
 
+msgid "MemberRole|minimal base access level must be %{min_access_level}."
+msgstr ""
+
 msgid "MemberRole|must be top-level namespace"
 msgstr ""