diff --git a/ee/app/models/authz/custom_ability.rb b/ee/app/models/authz/custom_ability.rb
index 0d2fe6e1be4ac2a02ad1ef84aec46aa3f0dce4ad..35b0c31f130f62bd52611d56fc7d301db78bab7e 100644
--- a/ee/app/models/authz/custom_ability.rb
+++ b/ee/app/models/authz/custom_ability.rb
@@ -52,17 +52,11 @@ def custom_roles_enabled?(resource)
     end
 
     def abilities_for_projects(user, projects)
-      ::Preloaders::UserMemberRolesInProjectsPreloader.new(
-        projects: projects,
-        user: user
-      ).execute
+      ::Authz::Project.new(user, scope: projects).permitted
     end
 
     def abilities_for_groups(user, groups)
-      ::Preloaders::UserMemberRolesInGroupsPreloader.new(
-        groups: groups,
-        user: user
-      ).execute
+      ::Authz::Group.new(user, scope: groups).permitted
     end
 
     def abilities_for(user, resource)
diff --git a/ee/app/models/authz/group.rb b/ee/app/models/authz/group.rb
new file mode 100644
index 0000000000000000000000000000000000000000..24febf02afd0c14efe6b09c8fd00375b0b0e1cc0
--- /dev/null
+++ b/ee/app/models/authz/group.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Authz
+  class Group < Resource
+    def initialize(user, scope: user.authorized_groups)
+      super(user, scope)
+    end
+
+    def permitted
+      ::Preloaders::UserMemberRolesInGroupsPreloader
+        .new(groups: scope, user: user)
+        .execute
+    end
+  end
+end
diff --git a/ee/app/models/authz/project.rb b/ee/app/models/authz/project.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5dd55f6cf361226e7f73c05b6dccb99096c69395
--- /dev/null
+++ b/ee/app/models/authz/project.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+module Authz
+  class Project < Resource
+    def initialize(user, scope: user.authorized_projects)
+      super(user, scope)
+    end
+
+    def permitted
+      ::Preloaders::UserMemberRolesInProjectsPreloader
+        .new(projects: scope, user: user)
+        .execute
+    end
+  end
+end
diff --git a/ee/app/models/authz/resource.rb b/ee/app/models/authz/resource.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d852a349a0f44f1b54e1adb66159f948b6c4c680
--- /dev/null
+++ b/ee/app/models/authz/resource.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Authz
+  class Resource
+    attr_reader :user, :scope
+
+    def initialize(user, scope)
+      @user = user
+      @scope = scope
+    end
+
+    def permitted
+      raise NotImplementedError
+    end
+
+    def permitted_to(permission, to_relation: true)
+      ids = permitted.filter_map do |id, permissions|
+        id if permission.in?(permissions || [])
+      end
+      to_relation ? scope.where(id: ids) : ids
+    end
+  end
+end
diff --git a/ee/app/models/ee/user.rb b/ee/app/models/ee/user.rb
index 58217bf960213cc627ddfbe479609075a9e74902..fb9418cfa3957da15937e832d677dd2a8a5109c9 100644
--- a/ee/app/models/ee/user.rb
+++ b/ee/app/models/ee/user.rb
@@ -723,6 +723,23 @@ def contributed_epic_groups
       ::Group.where(id: contributed_group_ids).not_aimed_for_deletion
     end
 
+    override :ci_owned_runners
+    def ci_owned_runners
+      ::Ci::Runner.from_union([
+        super,
+        ::Ci::Runner.belonging_to_group(groups_permitted_to(:admin_runners)),
+        ::Ci::Runner.belonging_to_project(projects_permitted_to(:admin_runners))
+      ])
+    end
+
+    def groups_permitted_to(permission)
+      ::Authz::Group.new(self).permitted_to(permission, to_relation: false)
+    end
+
+    def projects_permitted_to(permission)
+      ::Authz::Project.new(self).permitted_to(permission, to_relation: false)
+    end
+
     protected
 
     override :password_required?
diff --git a/ee/spec/factories/member_roles.rb b/ee/spec/factories/member_roles.rb
index 8b0829566c4d04b3b770112bbe918cb89509c6f4..7f7b56fe76f5639fb36c75f98e909f418be09c8b 100644
--- a/ee/spec/factories/member_roles.rb
+++ b/ee/spec/factories/member_roles.rb
@@ -7,12 +7,12 @@
     read_code { true }
     name { generate(:title) }
 
-    trait(:developer) { base_access_level { Gitlab::Access::DEVELOPER } }
-    trait(:maintainer) { base_access_level { Gitlab::Access::MAINTAINER } }
-    trait(:reporter) { base_access_level { Gitlab::Access::REPORTER } }
-    trait(:guest) { base_access_level { Gitlab::Access::GUEST } }
     trait(:minimal_access) { base_access_level { Gitlab::Access::MINIMAL_ACCESS } }
 
+    ::Gitlab::Access.sym_options_with_owner.each do |role, value|
+      trait(role) { base_access_level { value } }
+    end
+
     Gitlab::CustomRoles::Definition.all.each_value do |attributes|
       trait attributes[:name].to_sym do
         send(attributes[:name].to_sym) { true }
diff --git a/ee/spec/models/authz/group_spec.rb b/ee/spec/models/authz/group_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..444cd607e2861e573e57fc69f759910c7cb7ac3f
--- /dev/null
+++ b/ee/spec/models/authz/group_spec.rb
@@ -0,0 +1,146 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Authz::Group, feature_category: :system_access do
+  subject(:group_authorization) { scope ? described_class.new(user, scope: scope) : described_class.new(user) }
+
+  let(:scope) { nil }
+
+  let_it_be(:user, reload: true) { create(:user) }
+  let_it_be(:root_group) { create(:group) }
+  let_it_be(:group) { create(:group, parent: root_group) }
+  let_it_be(:child_group) { create(:group, parent: group) }
+
+  let_it_be(:admin_runners_role) { create(:member_role, :guest, :admin_runners, namespace: root_group) }
+  let_it_be(:admin_vulnerability_role) { create(:member_role, :guest, :admin_vulnerability, namespace: root_group) }
+  let_it_be(:read_dependency_role) { create(:member_role, :guest, :read_dependency, namespace: root_group) }
+
+  before do
+    stub_licensed_features(custom_roles: true)
+  end
+
+  describe "#permitted" do
+    subject(:permitted) { group_authorization.permitted }
+
+    context 'when authorized for different permissions at different levels in the group hierarchy' do
+      let_it_be(:memberships) do
+        [
+          [admin_runners_role, root_group],
+          [admin_vulnerability_role, group],
+          [read_dependency_role, child_group]
+        ]
+      end
+
+      before_all do
+        memberships.each do |(role, group)|
+          create(:group_member, :guest, member_role: role, user: user, source: group)
+        end
+      end
+
+      it { is_expected.to include(root_group.id => match_array([:admin_runners])) }
+
+      it do
+        permissions = [:admin_runners, :admin_vulnerability, :read_vulnerability]
+        is_expected.to include(group.id => match_array(permissions))
+      end
+
+      it do
+        is_expected.to include(child_group.id => match_array([
+          :admin_runners,
+          :admin_vulnerability,
+          :read_dependency,
+          :read_vulnerability
+        ]))
+      end
+    end
+  end
+
+  describe "#permitted_to" do
+    subject(:permitted_to) { group_authorization.permitted_to(custom_ability) }
+
+    let(:custom_ability) { :admin_runners }
+
+    it { is_expected.to be_empty }
+
+    context 'when authorized for a project' do
+      let_it_be(:project) { create(:project, group: group) }
+
+      before_all do
+        create(:project_member, :guest, member_role: admin_runners_role, user: user, source: project)
+      end
+
+      it { is_expected.to be_empty }
+    end
+
+    context 'when authorized for root group' do
+      before_all do
+        create(:group_member, :guest, member_role: admin_runners_role, user: user, source: root_group)
+      end
+
+      it { is_expected.to match_array([root_group, group, child_group]) }
+    end
+
+    context 'when authorized for group' do
+      before_all do
+        create(:group_member, :guest, member_role: admin_runners_role, user: user, source: group)
+      end
+
+      it { is_expected.to match_array([group, child_group]) }
+    end
+
+    context 'when authorized for child group' do
+      before_all do
+        create(:group_member, :guest, member_role: admin_runners_role, user: user, source: child_group)
+      end
+
+      it { is_expected.to match_array([child_group]) }
+    end
+
+    context 'when authorized for different permissions at different levels in the group hierarchy' do
+      let_it_be(:memberships) do
+        [
+          [admin_runners_role, root_group],
+          [admin_vulnerability_role, group],
+          [read_dependency_role, child_group]
+        ]
+      end
+
+      before_all do
+        memberships.each do |(role, group)|
+          create(:group_member, :guest, member_role: role, user: user, source: group)
+        end
+      end
+
+      describe ":admin_runners" do
+        let(:custom_ability) { :admin_runners }
+
+        it { is_expected.to match_array([root_group, group, child_group]) }
+
+        context 'when overriding the default scope' do
+          let(:scope) { ::Group.where(id: [group.id]) }
+
+          it { is_expected.to match_array([group]) }
+        end
+      end
+
+      describe ":admin_vulnerability" do
+        let(:custom_ability) { :admin_vulnerability }
+
+        it { is_expected.to match_array([group, child_group]) }
+      end
+
+      describe ":read_dependency" do
+        let(:custom_ability) { :read_dependency }
+
+        it { is_expected.to match_array([child_group]) }
+
+        context 'when overriding the default scope' do
+          let(:scope) { ::Group.where(id: [group.id]) }
+
+          it { is_expected.to be_empty }
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/models/authz/project_spec.rb b/ee/spec/models/authz/project_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1650a3d21744ffc057e51b7b6d70266f41fe4858
--- /dev/null
+++ b/ee/spec/models/authz/project_spec.rb
@@ -0,0 +1,183 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Authz::Project, feature_category: :system_access do
+  subject(:project_authorization) { scope ? described_class.new(user, scope: scope) : described_class.new(user) }
+
+  let(:scope) { nil }
+
+  let_it_be(:user, reload: true) { create(:user) }
+  let_it_be(:root_group) { create(:group) }
+  let_it_be(:group) { create(:group, parent: root_group) }
+  let_it_be(:child_group) { create(:group, parent: group) }
+
+  let_it_be(:root_project) { create(:project, group: root_group) }
+  let_it_be(:project) { create(:project, group: group) }
+  let_it_be(:child_project) { create(:project, group: child_group) }
+
+  let_it_be(:admin_runners_role) { create(:member_role, :guest, :admin_runners, namespace: root_group) }
+  let_it_be(:admin_vulnerability_role) { create(:member_role, :guest, :admin_vulnerability, namespace: root_group) }
+  let_it_be(:archive_project_role) { create(:member_role, :owner, :archive_project, namespace: root_group) }
+  let_it_be(:read_dependency_role) { create(:member_role, :guest, :read_dependency, namespace: root_group) }
+
+  before do
+    stub_licensed_features(custom_roles: true)
+  end
+
+  describe "#permitted" do
+    subject(:permitted) { project_authorization.permitted }
+
+    context 'when authorized for different permissions at different levels in the group hierarchy' do
+      let_it_be(:memberships) do
+        [
+          [admin_runners_role, root_group],
+          [admin_vulnerability_role, group],
+          [read_dependency_role, child_group],
+          [archive_project_role, child_project]
+        ]
+      end
+
+      before_all do
+        memberships.each do |(role, source)|
+          if source.is_a?(::Group)
+            create(:group_member, :guest, member_role: role, user: user, source: source)
+          else
+            create(:project_member, :guest, member_role: role, user: user, source: source)
+          end
+        end
+      end
+
+      it { is_expected.to include(root_project.id => include(:admin_runners)) }
+      it { is_expected.to include(project.id => include(:admin_runners, :admin_vulnerability)) }
+
+      it do
+        is_expected.to include(child_project.id => include(
+          :admin_runners,
+          :admin_vulnerability,
+          :read_dependency,
+          :archive_project
+        ))
+      end
+    end
+  end
+
+  describe "#permitted_to" do
+    subject(:permitted_to) { project_authorization.permitted_to(custom_ability) }
+
+    let(:custom_ability) { :admin_runners }
+
+    it { is_expected.to be_empty }
+
+    context 'when authorized for a project' do
+      let_it_be(:project) { create(:project, group: group) }
+
+      before_all do
+        create(:project_member, :guest, member_role: admin_runners_role, user: user, source: project)
+      end
+
+      it { is_expected.to match_array([project]) }
+    end
+
+    context 'when authorized for root group' do
+      before_all do
+        create(:group_member, :guest, member_role: admin_runners_role, user: user, source: root_group)
+      end
+
+      it { is_expected.to match_array([root_project, project, child_project]) }
+    end
+
+    context 'when authorized for group' do
+      before_all do
+        create(:group_member, :guest, member_role: admin_runners_role, user: user, source: group)
+      end
+
+      it { is_expected.to match_array([project, child_project]) }
+    end
+
+    context 'when authorized for child group' do
+      before_all do
+        create(:group_member, :guest, member_role: admin_runners_role, user: user, source: child_group)
+      end
+
+      it { is_expected.to match_array([child_project]) }
+    end
+
+    context 'when authorized for project' do
+      before_all do
+        create(:project_member, :guest, member_role: admin_runners_role, user: user, source: project)
+      end
+
+      it { is_expected.to match_array([project]) }
+    end
+
+    context 'when authorized for different permissions at different levels in the group hierarchy' do
+      let_it_be(:memberships) do
+        [
+          [admin_runners_role, root_group],
+          [admin_vulnerability_role, group],
+          [read_dependency_role, child_group],
+          [archive_project_role, child_project]
+        ]
+      end
+
+      before_all do
+        memberships.each do |(role, source)|
+          if source.is_a?(::Group)
+            create(:group_member, :guest, member_role: role, user: user, source: source)
+          else
+            create(:project_member, :guest, member_role: role, user: user, source: source)
+          end
+        end
+      end
+
+      describe ":admin_runners" do
+        let(:custom_ability) { :admin_runners }
+
+        it { is_expected.to match_array([root_project, project, child_project]) }
+
+        context 'when overriding the default scope' do
+          let(:scope) { ::Project.where(id: [project.id]) }
+
+          it { is_expected.to match_array([project]) }
+        end
+      end
+
+      describe ":admin_vulnerability" do
+        let(:custom_ability) { :admin_vulnerability }
+
+        it { is_expected.to match_array([project, child_project]) }
+
+        describe ":read_vulnerability" do
+          let(:custom_ability) { :read_vulnerability }
+
+          it { is_expected.to match_array([project, child_project]) }
+        end
+      end
+
+      describe ":read_dependency" do
+        let(:custom_ability) { :read_dependency }
+
+        it { is_expected.to match_array([child_project]) }
+
+        context 'when overriding the default scope' do
+          let(:scope) { ::Project.where(id: [project.id]) }
+
+          it { is_expected.to be_empty }
+        end
+      end
+
+      describe ":archive_project" do
+        let(:custom_ability) { :archive_project }
+
+        it { is_expected.to match_array([child_project]) }
+
+        context 'when overriding the default scope' do
+          let(:scope) { ::Project.where(id: [child_project.id]) }
+
+          it { is_expected.to match_array([child_project]) }
+        end
+      end
+    end
+  end
+end
diff --git a/ee/spec/models/authz/resource_spec.rb b/ee/spec/models/authz/resource_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..e5ac92c92115eef5d45f0466088160af8b99b05d
--- /dev/null
+++ b/ee/spec/models/authz/resource_spec.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "spec_helper"
+
+RSpec.describe Authz::Resource, feature_category: :system_access do
+  subject(:resource_authorization) { described_class.new(user, scope) }
+
+  let(:user) { build_stubbed(:user) }
+  let(:scope) { Group.none }
+
+  describe "#permitted" do
+    subject(:permitted) { resource_authorization.permitted }
+
+    it { expect { permitted }.to raise_error(NotImplementedError) }
+  end
+end
diff --git a/ee/spec/models/ee/user_spec.rb b/ee/spec/models/ee/user_spec.rb
index 218bf8e3da8cc7315b68ea85e36a830ac4c0440e..71d4510d0e434ebb80aa66dff888b1f625104292 100644
--- a/ee/spec/models/ee/user_spec.rb
+++ b/ee/spec/models/ee/user_spec.rb
@@ -4210,4 +4210,42 @@
       it { is_expected.to be_truthy }
     end
   end
+
+  describe '#ci_owned_runners' do
+    subject(:runners) { user.ci_owned_runners }
+
+    let_it_be(:user, reload: true) { create(:user) }
+    let_it_be(:group) { create(:group, name: "group") }
+    let_it_be(:subgroup) { create(:group, parent: group, name: "subgroup") }
+    let_it_be(:project) { create(:project, group: subgroup, name: "project") }
+    let_it_be(:role) { create(:member_role, :guest, :admin_runners, namespace: group) }
+
+    let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group]) }
+    let_it_be(:subgroup_runner) { create(:ci_runner, :group, groups: [subgroup]) }
+    let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project]) }
+
+    before do
+      stub_licensed_features(custom_roles: true)
+    end
+
+    it { expect(runners).to be_empty }
+
+    context 'when the user has the `admin_runners` permission via a custom role on a root group' do
+      let_it_be(:membership) { create(:group_member, :guest, member_role: role, user: user, source: group) }
+
+      it { expect(runners).to match_array([group_runner, subgroup_runner, project_runner]) }
+    end
+
+    context 'when the user has the `admin_runners` permission via a custom role on a sub group' do
+      let_it_be(:membership) { create(:group_member, :guest, member_role: role, user: user, source: subgroup) }
+
+      it { expect(runners).to match_array([subgroup_runner, project_runner]) }
+    end
+
+    context 'when the user has the `admin_runners` permission via a custom role on a project' do
+      let_it_be(:membership) { create(:project_member, :guest, member_role: role, user: user, source: project) }
+
+      it { expect(runners).to match_array([project_runner]) }
+    end
+  end
 end
diff --git a/ee/spec/requests/custom_roles/admin_runners/request_spec.rb b/ee/spec/requests/custom_roles/admin_runners/request_spec.rb
index 97c2dd77f8997d91977530d123754df020bec945..b76cb0beb8a14cf625ee21f0886857cfcd5c922b 100644
--- a/ee/spec/requests/custom_roles/admin_runners/request_spec.rb
+++ b/ee/spec/requests/custom_roles/admin_runners/request_spec.rb
@@ -193,7 +193,7 @@
     let_it_be(:membership) { create(:group_member, :guest, member_role: role, user: user, source: group) }
     let_it_be(:project_runner) { create(:ci_runner, :project, description: 'Project runner', projects: [project]) }
 
-    pending "GET /runners" do
+    it "GET /runners" do
       get api("/runners", user)
 
       expect(response).to have_gitlab_http_status(:ok)