diff --git a/app/models/concerns/analytics/cycle_analytics/stage.rb b/app/models/concerns/analytics/cycle_analytics/stage.rb
index 0c603c2d5e614d1f0d08ec97eb9c28f33da39438..0011ba10a9aed2405501642915b9ceab51cea92a 100644
--- a/app/models/concerns/analytics/cycle_analytics/stage.rb
+++ b/app/models/concerns/analytics/cycle_analytics/stage.rb
@@ -7,6 +7,7 @@ module Stage
 
       included do
         validates :name, presence: true
+        validates :name, exclusion: { in: Gitlab::Analytics::CycleAnalytics::DefaultStages.names }, if: :custom?
         validates :start_event_identifier, presence: true
         validates :end_event_identifier, presence: true
         validate :validate_stage_event_pairs
@@ -15,6 +16,7 @@ module Stage
         enum end_event_identifier: Gitlab::Analytics::CycleAnalytics::StageEvents.to_enum, _prefix: :end_event_identifier
 
         alias_attribute :custom_stage?, :custom
+        scope :default_stages, -> { where(custom: false) }
       end
 
       def parent=(_)
diff --git a/ee/app/controllers/analytics/cycle_analytics/stages_controller.rb b/ee/app/controllers/analytics/cycle_analytics/stages_controller.rb
index 9bc7d27e651abdd06abf04b5cd7d17d5c9654cd7..b5f2dafe7bd1f51ec8b3712de3f04488d6646fa4 100644
--- a/ee/app/controllers/analytics/cycle_analytics/stages_controller.rb
+++ b/ee/app/controllers/analytics/cycle_analytics/stages_controller.rb
@@ -31,7 +31,7 @@ def cycle_analytics_configuration(stages)
       end
 
       def stage_list_service
-        Analytics::CycleAnalytics::StageListService.new(
+        Analytics::CycleAnalytics::Stages::ListService.new(
           parent: @group,
           current_user: current_user
         )
diff --git a/ee/app/finders/analytics/cycle_analytics/stage_finder.rb b/ee/app/finders/analytics/cycle_analytics/stage_finder.rb
new file mode 100644
index 0000000000000000000000000000000000000000..5293fd3f3c6ba20f793f5861b9d16c1b66381285
--- /dev/null
+++ b/ee/app/finders/analytics/cycle_analytics/stage_finder.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+module Analytics
+  module CycleAnalytics
+    class StageFinder
+      NUMBERS_ONLY = /\A\d+\z/.freeze
+
+      def initialize(parent:, stage_id:)
+        @parent = parent
+        @stage_id = stage_id
+      end
+
+      def execute
+        if in_memory_default_stage?
+          build_in_memory_stage_by_name
+        else
+          parent.cycle_analytics_stages.find(stage_id)
+        end
+      end
+
+      private
+
+      attr_reader :parent, :stage_id
+
+      def in_memory_default_stage?
+        !NUMBERS_ONLY.match?(stage_id.to_s)
+      end
+
+      def build_in_memory_stage_by_name
+        parent.cycle_analytics_stages.build(find_in_memory_stage)
+      end
+
+      def find_in_memory_stage
+        # raise ActiveRecord::RecordNotFound, so it will behave similarly to AR models and produce 404 response in the controller
+        raw_stage = Gitlab::Analytics::CycleAnalytics::DefaultStages.all.find do |hash|
+          hash[:name].eql?(stage_id)
+        end
+
+        raise(ActiveRecord::RecordNotFound, "Stage with id '#{stage_id}' could not be found") unless raw_stage
+
+        raw_stage
+      end
+    end
+  end
+end
diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb
index e6bea33964f879105bcecf449da6c22c15cf9bed..fb73d671d8a5759ac0c73891ecbbd878b2e426a0 100644
--- a/ee/app/policies/ee/group_policy.rb
+++ b/ee/app/policies/ee/group_policy.rb
@@ -69,8 +69,9 @@ module GroupPolicy
       rule { can?(:read_group) & contribution_analytics_available }
         .enable :read_group_contribution_analytics
 
-      rule { reporter & cycle_analytics_available }
-        .enable :read_group_cycle_analytics
+      rule { reporter & cycle_analytics_available }.policy do
+        enable :read_group_cycle_analytics, :create_group_stage, :read_group_stage, :update_group_stage, :delete_group_stage
+      end
 
       rule { can?(:read_group) & dependency_proxy_available }
         .enable :read_dependency_proxy
diff --git a/ee/app/services/analytics/cycle_analytics/stage_list_service.rb b/ee/app/services/analytics/cycle_analytics/stage_list_service.rb
deleted file mode 100644
index 3bef8eda67d99207f24bf2b5b473a3f9bd7f0825..0000000000000000000000000000000000000000
--- a/ee/app/services/analytics/cycle_analytics/stage_list_service.rb
+++ /dev/null
@@ -1,42 +0,0 @@
-# frozen_string_literal: true
-
-module Analytics
-  module CycleAnalytics
-    class StageListService
-      include Gitlab::Allowable
-
-      def initialize(parent:, current_user:)
-        @parent = parent
-        @current_user = current_user
-      end
-
-      def execute
-        return forbidden unless allowed?
-
-        success(build_default_stages)
-      end
-
-      private
-
-      attr_reader :parent, :current_user
-
-      def build_default_stages
-        Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map do |params|
-          parent.cycle_analytics_stages.build(params)
-        end
-      end
-
-      def success(stages)
-        ServiceResponse.success(payload: { stages: stages })
-      end
-
-      def forbidden
-        ServiceResponse.error(message: 'Forbidden', http_status: :forbidden)
-      end
-
-      def allowed?
-        can?(current_user, :read_group_cycle_analytics, parent)
-      end
-    end
-  end
-end
diff --git a/ee/app/services/analytics/cycle_analytics/stages/base_service.rb b/ee/app/services/analytics/cycle_analytics/stages/base_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..625d45dbaaec3f21960e80c2ff12db532cb5340c
--- /dev/null
+++ b/ee/app/services/analytics/cycle_analytics/stages/base_service.rb
@@ -0,0 +1,55 @@
+# frozen_string_literal: true
+
+module Analytics
+  module CycleAnalytics
+    class BaseService
+      include Gitlab::Allowable
+
+      def initialize(parent:, current_user:, params: {})
+        @parent = parent
+        @current_user = current_user
+      end
+
+      def execute
+        raise NotImplementedError
+      end
+
+      private
+
+      attr_reader :parent, :current_user, :params
+
+      def success(stage, http_status = :created)
+        ServiceResponse.success(payload: { stage: stage }, http_status: http_status)
+      end
+
+      def error(stage)
+        ServiceResponse.error(message: 'Invalid parameters', payload: { errors: stage.errors }, http_status: :unprocessable_entity)
+      end
+
+      def not_found
+        ServiceResponse.error(message: 'Stage not found', payload: {}, http_status: :not_found)
+      end
+
+      def forbidden
+        ServiceResponse.error(message: 'Forbidden', payload: {}, http_status: :forbidden)
+      end
+
+      def persist_default_stages!
+        persisted_default_stages = parent.cycle_analytics_stages.default_stages
+
+        # make sure that we persist default stages only once
+        stages_to_persist = build_default_stages.select do |new_default_stage|
+          !persisted_default_stages.find { |s| s.name.eql?(new_default_stage.name) }
+        end
+
+        stages_to_persist.each(&:save!)
+      end
+
+      def build_default_stages
+        Gitlab::Analytics::CycleAnalytics::DefaultStages.all.map do |params|
+          parent.cycle_analytics_stages.build(params)
+        end
+      end
+    end
+  end
+end
diff --git a/ee/app/services/analytics/cycle_analytics/stages/create_service.rb b/ee/app/services/analytics/cycle_analytics/stages/create_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..84e46b71195013c4c2c9ab1a10bed9975d298d21
--- /dev/null
+++ b/ee/app/services/analytics/cycle_analytics/stages/create_service.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Analytics
+  module CycleAnalytics
+    module Stages
+      class CreateService < BaseService
+        def initialize(parent:, current_user:, params:)
+          super
+
+          @stage = parent.cycle_analytics_stages.build(params)
+        end
+
+        def execute
+          return forbidden unless can?(current_user, :create_group_stage, parent)
+          return error(stage) unless stage.valid?
+
+          parent.class.transaction do
+            persist_default_stages!
+            stage.save!
+          end
+
+          success(stage)
+        end
+
+        private
+
+        attr_reader :stage
+      end
+    end
+  end
+end
diff --git a/ee/app/services/analytics/cycle_analytics/stages/list_service.rb b/ee/app/services/analytics/cycle_analytics/stages/list_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..05d3807ab6224c57f0844de60e96fbd0df786d2b
--- /dev/null
+++ b/ee/app/services/analytics/cycle_analytics/stages/list_service.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Analytics
+  module CycleAnalytics
+    module Stages
+      class ListService < BaseService
+        def execute
+          return forbidden unless can?(current_user, :read_group_cycle_analytics, parent)
+
+          success(persisted_stages.presence || build_default_stages)
+        end
+
+        private
+
+        def success(stages)
+          ServiceResponse.success(payload: { stages: stages })
+        end
+
+        def persisted_stages
+          parent.cycle_analytics_stages
+        end
+      end
+    end
+  end
+end
diff --git a/ee/app/services/analytics/cycle_analytics/stages/update_service.rb b/ee/app/services/analytics/cycle_analytics/stages/update_service.rb
new file mode 100644
index 0000000000000000000000000000000000000000..2be090553812e26788309b818f96de496a155211
--- /dev/null
+++ b/ee/app/services/analytics/cycle_analytics/stages/update_service.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Analytics
+  module CycleAnalytics
+    module Stages
+      class UpdateService < BaseService
+        def initialize(parent:, current_user:, params:)
+          super
+
+          @params = params
+        end
+
+        def execute
+          return forbidden unless can?(current_user, :update_group_stage, parent)
+
+          parent.cycle_analytics_stages.model.transaction do
+            persist_default_stages!
+
+            @stage = find_stage
+            @stage.assign_attributes(filtered_params)
+
+            raise ActiveRecord::Rollback unless @stage.valid?
+
+            @stage.save!
+          end
+
+          @stage.valid? ? success(@stage, :ok) : error(@stage)
+        end
+
+        private
+
+        def filtered_params
+          {}.tap do |new_params|
+            if default_stage?
+              new_params[:hidden] = params[:hidden] # for default stage only hidden parameter is allowed
+            else
+              new_params.merge!(params)
+            end
+          end.compact
+        end
+
+        def default_stage?
+          Gitlab::Analytics::CycleAnalytics::DefaultStages.names.include?(params[:id])
+        end
+
+        # rubocop: disable CodeReuse/ActiveRecord
+        def find_stage
+          if default_stage?
+            # default stages are already persisted
+            parent.cycle_analytics_stages.find_by!(name: params[:id])
+          else
+            parent.cycle_analytics_stages.find(params[:id])
+          end
+        end
+        # rubocop: enable CodeReuse/ActiveRecord
+      end
+    end
+  end
+end
diff --git a/ee/spec/controllers/analytics/cycle_analytics/stages_controller_spec.rb b/ee/spec/controllers/analytics/cycle_analytics/stages_controller_spec.rb
index b227c636f09dbb0b7362de47412777b64a511fdc..9084596fd9da791048749e6c82650a3c0a326e68 100644
--- a/ee/spec/controllers/analytics/cycle_analytics/stages_controller_spec.rb
+++ b/ee/spec/controllers/analytics/cycle_analytics/stages_controller_spec.rb
@@ -93,7 +93,7 @@
   end
 
   it 'renders 403 based on the response of the service object' do
-    expect_any_instance_of(Analytics::CycleAnalytics::StageListService).to receive(:allowed?).and_return(false)
+    expect_any_instance_of(Analytics::CycleAnalytics::Stages::ListService).to receive(:can?).and_return(false)
 
     subject
 
diff --git a/ee/spec/factories/analytics/cycle_analytics/group_stages.rb b/ee/spec/factories/analytics/cycle_analytics/group_stages.rb
new file mode 100644
index 0000000000000000000000000000000000000000..1a94cfbc2c2d384984665db7c59f49d0b082ae51
--- /dev/null
+++ b/ee/spec/factories/analytics/cycle_analytics/group_stages.rb
@@ -0,0 +1,10 @@
+# frozen_string_literal: true
+
+FactoryBot.define do
+  factory :cycle_analytics_group_stage, class: Analytics::CycleAnalytics::GroupStage do
+    sequence(:name) { |n| "Stage ##{n}" }
+    start_event_identifier { Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestCreated.identifier }
+    end_event_identifier { Gitlab::Analytics::CycleAnalytics::StageEvents::MergeRequestMerged.identifier }
+    group
+  end
+end
diff --git a/ee/spec/finders/analytics/cycle_analytics/stage_finder_spec.rb b/ee/spec/finders/analytics/cycle_analytics/stage_finder_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..d97a3bc388a6fdd92a15d506073bba753743d940
--- /dev/null
+++ b/ee/spec/finders/analytics/cycle_analytics/stage_finder_spec.rb
@@ -0,0 +1,42 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Analytics::CycleAnalytics::StageFinder do
+  let_it_be(:group) { create(:group) }
+  let(:stage_id) { { id: Gitlab::Analytics::CycleAnalytics::DefaultStages.names.first } }
+
+  subject { described_class.new(parent: group, stage_id: stage_id[:id]).execute }
+
+  context 'when looking up in-memory default stage by name exists' do
+    it { expect(subject).not_to be_persisted }
+    it { expect(subject.name).to eq(stage_id[:id]) }
+  end
+
+  context 'when in-memory default stage cannot be found' do
+    before do
+      stage_id[:id] = 'unknown_default_stage'
+    end
+
+    it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) }
+  end
+
+  context 'when persisted stage exists' do
+    let(:stage) { create(:cycle_analytics_group_stage, group: group) }
+
+    before do
+      stage_id[:id] = stage.id
+    end
+
+    it { expect(subject).to be_persisted }
+    it { expect(subject.name).to eq(stage.name) }
+  end
+
+  context 'when persisted stage cannot be found' do
+    before do
+      stage_id[:id] = -1
+    end
+
+    it { expect { subject }.to raise_error(ActiveRecord::RecordNotFound) }
+  end
+end
diff --git a/ee/spec/services/analytics/cycle_analytics/stage_list_service_spec.rb b/ee/spec/services/analytics/cycle_analytics/stage_list_service_spec.rb
deleted file mode 100644
index 3025282b4822226f717c6ef931e5ea3e7cde7195..0000000000000000000000000000000000000000
--- a/ee/spec/services/analytics/cycle_analytics/stage_list_service_spec.rb
+++ /dev/null
@@ -1,35 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-describe Analytics::CycleAnalytics::StageListService do
-  let(:group) { create(:group) }
-  let(:user) { create(:user) }
-  subject { described_class.new(parent: group, current_user: user) }
-
-  context 'succeeds' do
-    let(:stages) { subject.execute.payload[:stages] }
-
-    before do
-      stub_licensed_features(cycle_analytics_for_groups: true)
-
-      group.add_reporter(user)
-    end
-
-    it 'returns only the default stages' do
-      expect(stages.size).to eq(Gitlab::Analytics::CycleAnalytics::DefaultStages.all.size)
-    end
-
-    it 'provides the default stages as non-persisted objects' do
-      stage_ids = stages.map(&:id)
-      expect(stage_ids.all?(&:nil?)).to eq(true)
-    end
-  end
-
-  it 'returns forbidden response' do
-    result = subject.execute
-
-    expect(result).to be_error
-    expect(result.http_status).to eq(:forbidden)
-  end
-end
diff --git a/ee/spec/services/analytics/cycle_analytics/stages/create_service_spec.rb b/ee/spec/services/analytics/cycle_analytics/stages/create_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..386d4df0236826cbf988c5140396cfb686886aa6
--- /dev/null
+++ b/ee/spec/services/analytics/cycle_analytics/stages/create_service_spec.rb
@@ -0,0 +1,89 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Analytics::CycleAnalytics::Stages::CreateService do
+  let_it_be(:group, refind: true) { create(:group) }
+  let_it_be(:user, refind: true) { create(:user) }
+  let(:params) { { name: 'my stage', start_event_identifier: :merge_request_created, end_event_identifier: :merge_request_merged } }
+
+  before_all do
+    group.add_user(user, :reporter)
+  end
+
+  before do
+    stub_licensed_features(cycle_analytics_for_groups: true)
+  end
+
+  subject { described_class.new(parent: group, params: params, current_user: user).execute }
+
+  it_behaves_like 'permission check for cycle analytics stage services', :cycle_analytics_for_groups
+
+  describe 'custom stage creation' do
+    context 'when service response is successful' do
+      let(:stage) { subject.payload[:stage] }
+
+      it { expect(subject).to be_success }
+      it { expect(subject.http_status).to eq(:created) }
+      it { expect(stage).to be_present }
+      it { expect(stage).to be_persisted }
+      it { expect(stage.start_event_identifier).to eq(params[:start_event_identifier].to_s) }
+      it { expect(stage.end_event_identifier).to eq(params[:end_event_identifier].to_s) }
+    end
+  end
+
+  context 'when params are invalid' do
+    before do
+      params.delete(:name)
+    end
+
+    it { expect(subject).to be_error }
+    it { expect(subject.http_status).to eq(:unprocessable_entity) }
+    it { expect(subject.payload[:errors].keys).to eq([:name]) }
+  end
+
+  describe 'persistence of default stages' do
+    let(:persisted_stages) { group.cycle_analytics_stages }
+    let(:customized_stages) { group.cycle_analytics_stages.where(custom: true) }
+    let(:default_stages) { Gitlab::Analytics::CycleAnalytics::DefaultStages.all }
+    let(:expected_stage_count) { default_stages.count + customized_stages.count }
+
+    context 'when creating custom stages' do
+      it { expect(subject).to be_success }
+
+      it 'persists all default stages' do
+        subject
+
+        expect(persisted_stages.count).to eq(expected_stage_count)
+      end
+
+      context 'when creating two custom stages' do
+        before do
+          described_class.new(parent: group, params: params.merge(name: 'other stage'), current_user: user).execute
+        end
+
+        it 'creates two customized stages' do
+          subject
+
+          expect(customized_stages.count).to eq(2)
+        end
+
+        it 'creates records for the default stages only once plus two customized stage records' do
+          expect(group.cycle_analytics_stages.count).to eq(expected_stage_count)
+        end
+      end
+    end
+
+    context 'when params are invalid' do
+      before do
+        params.delete(:name)
+      end
+
+      it { expect(subject).to be_error }
+
+      it 'skips persisting default stages on validation error' do
+        expect(group.cycle_analytics_stages.count).to eq(0)
+      end
+    end
+  end
+end
diff --git a/ee/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb b/ee/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..87f2de3a9856c2782b4af67e70b1dcd9301076f2
--- /dev/null
+++ b/ee/spec/services/analytics/cycle_analytics/stages/list_service_spec.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Analytics::CycleAnalytics::Stages::ListService do
+  let_it_be(:group, refind: true) { create(:group) }
+  let_it_be(:user) { create(:user) }
+  let(:stages) { subject.payload[:stages] }
+
+  subject { described_class.new(parent: group, current_user: user).execute }
+
+  before_all do
+    group.add_reporter(user)
+  end
+
+  before do
+    stub_licensed_features(cycle_analytics_for_groups: true)
+  end
+
+  it_behaves_like 'permission check for cycle analytics stage services', :cycle_analytics_for_groups
+
+  it 'returns only the default stages' do
+    expect(stages.size).to eq(Gitlab::Analytics::CycleAnalytics::DefaultStages.all.size)
+  end
+
+  it 'provides the default stages as non-persisted objects' do
+    expect(stages.map(&:id)).to all(be_nil)
+  end
+
+  context 'when there are persisted stages' do
+    let!(:stage1) { create(:cycle_analytics_group_stage, parent: group) }
+    let!(:stage2) { create(:cycle_analytics_group_stage, parent: group) }
+
+    it 'returns the persisted stages' do
+      expect(stages).to contain_exactly(stage1, stage2)
+    end
+  end
+end
diff --git a/ee/spec/services/analytics/cycle_analytics/stages/update_service_spec.rb b/ee/spec/services/analytics/cycle_analytics/stages/update_service_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..764e82c3c3d1ca95a11ec5691c9b03b56c353902
--- /dev/null
+++ b/ee/spec/services/analytics/cycle_analytics/stages/update_service_spec.rb
@@ -0,0 +1,115 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+describe Analytics::CycleAnalytics::Stages::UpdateService do
+  let_it_be(:group, refind: true) { create(:group) }
+  let_it_be(:user, refind: true) { create(:user) }
+  let(:default_stages) { Gitlab::Analytics::CycleAnalytics::DefaultStages.all }
+  let(:params) { {} }
+
+  subject { described_class.new(parent: group, params: params, current_user: user).execute }
+
+  before_all do
+    group.add_user(user, :reporter)
+  end
+
+  before do
+    stub_licensed_features(cycle_analytics_for_groups: true)
+  end
+
+  it_behaves_like 'permission check for cycle analytics stage services', :cycle_analytics_for_groups
+
+  context 'when updating a default stage' do
+    let(:stage) { Analytics::CycleAnalytics::GroupStage.new(default_stages.first.merge(group: group)) }
+    let(:params) { { id: stage.name, hidden: true } }
+    let(:updated_stage) { subject.payload[:stage] }
+
+    context 'when hiding a default stage' do
+      it { expect(subject).to be_success }
+      it { expect(updated_stage).to be_persisted }
+      it { expect(updated_stage).to be_hidden }
+    end
+
+    context 'when other parameters than "hidden" are given' do
+      before do
+        params[:name] = 'should not be updated'
+      end
+
+      it { expect(subject).to be_success }
+      it { expect(updated_stage.name).not_to eq(params[:name]) }
+    end
+
+    context 'when the first update happens on a default stage' do
+      let(:persisted_stages) { group.reload.cycle_analytics_stages }
+
+      it { expect(subject).to be_success }
+
+      it 'persists all default stages' do
+        subject
+
+        expect(persisted_stages.count).to eq(default_stages.count)
+        expect(persisted_stages).to all(be_persisted)
+      end
+
+      it 'matches with the configured default stage name' do
+        subject
+
+        default_stage_names = default_stages.map { |s| s[:name] }
+        expect(default_stage_names).to include(updated_stage.name)
+      end
+
+      context 'when the update fails' do
+        before do
+          invalid_stage = Analytics::CycleAnalytics::GroupStage.new(name: '')
+          expect_any_instance_of(described_class).to receive(:find_stage).and_return(invalid_stage)
+        end
+
+        it 'returns unsuccessful service response' do
+          subject
+
+          expect(subject).not_to be_success
+        end
+
+        it 'does not persist the default stages if the stage is invalid' do
+          subject
+
+          expect(persisted_stages).not_to include(be_persisted)
+        end
+      end
+    end
+
+    context 'when updating an already persisted default stage' do
+      let(:persisted_stage) { subject.payload[:stage] }
+
+      let(:updated_stage) do
+        described_class
+          .new(parent: group, params: { id: persisted_stage.id, hidden: false }, current_user: user)
+          .execute
+          .payload[:stage]
+      end
+
+      it { expect(updated_stage).to be_persisted }
+      it { expect(updated_stage).not_to be_hidden }
+    end
+  end
+
+  context 'when updating a custom stage' do
+    let_it_be(:stage) { create(:cycle_analytics_group_stage, group: group) }
+    let(:params) { { id: stage.id, name: 'my new stage name' } }
+
+    it { expect(subject).to be_success }
+    it { expect(subject.http_status).to eq(:ok) }
+    it { expect(subject.payload[:stage].name).to eq(params[:name]) }
+
+    context 'when params are invalid' do
+      before do
+        params[:name] = ''
+      end
+
+      it { expect(subject).to be_error }
+      it { expect(subject.http_status).to eq(:unprocessable_entity) }
+      it { expect(subject.payload[:errors].keys).to eq([:name]) }
+    end
+  end
+end
diff --git a/ee/spec/support/shared_examples/services/analytics/cycle_analytics/stages_shared_examples.rb b/ee/spec/support/shared_examples/services/analytics/cycle_analytics/stages_shared_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..616ca3fe8cef14c9962716ef46e3bd9008f785f3
--- /dev/null
+++ b/ee/spec/support/shared_examples/services/analytics/cycle_analytics/stages_shared_examples.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+shared_examples 'permission check for cycle analytics stage services' do |required_license|
+  context 'when user has no access' do
+    before do
+      group.add_user(user, :guest)
+    end
+
+    it { expect(subject).to be_error }
+    it { expect(subject.http_status).to eq(:forbidden) }
+  end
+
+  context 'when license is missing' do
+    before do
+      stub_licensed_features(required_license => false)
+    end
+
+    it { expect(subject).to be_error }
+    it { expect(subject.http_status).to eq(:forbidden) }
+  end
+end
diff --git a/lib/gitlab/analytics/cycle_analytics/default_stages.rb b/lib/gitlab/analytics/cycle_analytics/default_stages.rb
index 286c393005ffb74506342fdaf63333adfa352fcc..711645800fb5ffb87f42215c10ad69b8c3774f31 100644
--- a/lib/gitlab/analytics/cycle_analytics/default_stages.rb
+++ b/lib/gitlab/analytics/cycle_analytics/default_stages.rb
@@ -23,6 +23,10 @@ def self.all
           ]
         end
 
+        def self.names
+          all.map { |stage| stage[:name] }
+        end
+
         def self.params_for_issue_stage
           {
             name: 'issue',
diff --git a/spec/support/shared_examples/cycle_analytics_stage_examples.rb b/spec/support/shared_examples/cycle_analytics_stage_examples.rb
index 151f5325e84bf0acd414da5ef33c6fc69987d099..dc2ea229171076da4efcd7b63ae2bfe56ce0e04d 100644
--- a/spec/support/shared_examples/cycle_analytics_stage_examples.rb
+++ b/spec/support/shared_examples/cycle_analytics_stage_examples.rb
@@ -46,6 +46,13 @@
       expect(stage).not_to be_valid
       expect(stage.errors.details[:end_event]).to eq([{ error: :not_allowed_for_the_given_start_event }])
     end
+
+    context 'disallows default stage names when creating custom stage' do
+      let(:invalid_params) { valid_params.merge(name: Gitlab::Analytics::CycleAnalytics::DefaultStages.names.first, custom: true) }
+      let(:stage) { described_class.new(invalid_params) }
+
+      it { expect(stage).not_to be_valid }
+    end
   end
 
   describe '#subject_model' do