From a1ba12d1deceabb3de951c800838efab4f2ccc76 Mon Sep 17 00:00:00 2001
From: Dmytro Biryukov <dbiryukov@gitlab.com>
Date: Wed, 15 Nov 2023 09:52:21 +0000
Subject: [PATCH] Exclude protected variables from multi-project pipeline
 bridge triggers

---
 app/models/ci/bridge.rb                       |  27 ++-
 app/models/concerns/ci/contextable.rb         |  19 ++
 ...s_from_multi_project_pipeline_triggers.yml |   8 +
 lib/gitlab/ci/variables/builder.rb            |  31 ++-
 spec/models/ci/bridge_spec.rb                 | 197 ++++++++++++++++++
 5 files changed, 275 insertions(+), 7 deletions(-)
 create mode 100644 config/feature_flags/development/exclude_protected_variables_from_multi_project_pipeline_triggers.yml

diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb
index cf6401dc1daaa..89f8520b33122 100644
--- a/app/models/ci/bridge.rb
+++ b/app/models/ci/bridge.rb
@@ -220,8 +220,19 @@ def downstream_variables
 
     def variables
       strong_memoize(:variables) do
+        bridge_variables =
+          if ::Feature.disabled?(:exclude_protected_variables_from_multi_project_pipeline_triggers, project) ||
+              (expose_protected_project_variables? && expose_protected_group_variables?)
+            scoped_variables
+          else
+            unprotected_scoped_variables(
+              expose_project_variables: expose_protected_project_variables?,
+              expose_group_variables: expose_protected_group_variables?
+            )
+          end
+
         Gitlab::Ci::Variables::Collection.new
-         .concat(scoped_variables)
+         .concat(bridge_variables)
          .concat(pipeline.persisted_variables)
       end
     end
@@ -260,6 +271,20 @@ def expand_file_refs?
 
     private
 
+    def expose_protected_group_variables?
+      return true if downstream_project.nil?
+      return true if project.group.present? && project.group == downstream_project.group
+
+      false
+    end
+
+    def expose_protected_project_variables?
+      return true if downstream_project.nil?
+      return true if project.id == downstream_project.id
+
+      false
+    end
+
     def cross_project_params
       {
         project: downstream_project,
diff --git a/app/models/concerns/ci/contextable.rb b/app/models/concerns/ci/contextable.rb
index 88b7bb89b899e..dcbee5296376c 100644
--- a/app/models/concerns/ci/contextable.rb
+++ b/app/models/concerns/ci/contextable.rb
@@ -17,6 +17,25 @@ def scoped_variables(environment: expanded_environment_name, dependencies: true)
       end
     end
 
+    def unprotected_scoped_variables(
+      expose_project_variables:,
+      expose_group_variables:,
+      environment: expanded_environment_name,
+      dependencies: true)
+
+      track_duration do
+        pipeline
+          .variables_builder
+          .unprotected_scoped_variables(
+            self,
+            expose_project_variables: expose_project_variables,
+            expose_group_variables: expose_group_variables,
+            environment: environment,
+            dependencies: dependencies
+          )
+      end
+    end
+
     def track_duration
       start_time = ::Gitlab::Metrics::System.monotonic_time
       result = yield
diff --git a/config/feature_flags/development/exclude_protected_variables_from_multi_project_pipeline_triggers.yml b/config/feature_flags/development/exclude_protected_variables_from_multi_project_pipeline_triggers.yml
new file mode 100644
index 0000000000000..57339a95f6b0d
--- /dev/null
+++ b/config/feature_flags/development/exclude_protected_variables_from_multi_project_pipeline_triggers.yml
@@ -0,0 +1,8 @@
+---
+name: exclude_protected_variables_from_multi_project_pipeline_triggers
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/135899
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/431266
+milestone: '16.6'
+type: development
+group: group::pipeline security
+default_enabled: true
\ No newline at end of file
diff --git a/lib/gitlab/ci/variables/builder.rb b/lib/gitlab/ci/variables/builder.rb
index c279af6acfc51..d0bd26e995b2c 100644
--- a/lib/gitlab/ci/variables/builder.rb
+++ b/lib/gitlab/ci/variables/builder.rb
@@ -34,6 +34,25 @@ def scoped_variables(job, environment:, dependencies:)
           end
         end
 
+        def unprotected_scoped_variables(job, expose_project_variables:, expose_group_variables:, environment:, dependencies:)
+          Gitlab::Ci::Variables::Collection.new.tap do |variables|
+            variables.concat(predefined_variables(job, environment))
+            variables.concat(project.predefined_variables)
+            variables.concat(pipeline_variables_builder.predefined_variables)
+            variables.concat(job.runner.predefined_variables) if job.runnable? && job.runner
+            variables.concat(kubernetes_variables(environment: environment, job: job))
+            variables.concat(job.yaml_variables)
+            variables.concat(user_variables(job.user))
+            variables.concat(job.dependency_variables) if dependencies
+            variables.concat(secret_instance_variables)
+            variables.concat(secret_group_variables(environment: environment, include_protected_vars: expose_group_variables))
+            variables.concat(secret_project_variables(environment: environment, include_protected_vars: expose_project_variables))
+            variables.concat(pipeline.variables)
+            variables.concat(pipeline_schedule_variables)
+            variables.concat(release_variables)
+          end
+        end
+
         def config_variables
           Gitlab::Ci::Variables::Collection.new.tap do |variables|
             break variables unless project
@@ -91,21 +110,21 @@ def secret_instance_variables
           end
         end
 
-        def secret_group_variables(environment:)
-          strong_memoize_with(:secret_group_variables, environment) do
+        def secret_group_variables(environment:, include_protected_vars: protected_ref?)
+          strong_memoize_with(:secret_group_variables, environment, include_protected_vars) do
             group_variables_builder
               .secret_variables(
                 environment: environment,
-                protected_ref: protected_ref?)
+                protected_ref: include_protected_vars)
           end
         end
 
-        def secret_project_variables(environment:)
-          strong_memoize_with(:secret_project_variables, environment) do
+        def secret_project_variables(environment:, include_protected_vars: protected_ref?)
+          strong_memoize_with(:secret_project_variables, environment, include_protected_vars) do
             project_variables_builder
               .secret_variables(
                 environment: environment,
-                protected_ref: protected_ref?)
+                protected_ref: include_protected_vars)
           end
         end
 
diff --git a/spec/models/ci/bridge_spec.rb b/spec/models/ci/bridge_spec.rb
index 1d0c3bb5deeca..775695b406c9a 100644
--- a/spec/models/ci/bridge_spec.rb
+++ b/spec/models/ci/bridge_spec.rb
@@ -595,6 +595,203 @@
     end
   end
 
+  describe 'variables expansion' do
+    let(:options) do
+      {
+        trigger: {
+          project: 'my/project',
+          branch: 'master',
+          forward: { yaml_variables: true,
+                     pipeline_variables: true }.compact
+        }
+      }
+    end
+
+    let(:yaml_variables) do
+      [
+        {
+          key: 'EXPANDED_PROJECT_VAR6',
+          value: 'project value6 $PROJECT_PROTECTED_VAR'
+        },
+        {
+          key: 'EXPANDED_GROUP_VAR6',
+          value: 'group value6 $GROUP_PROTECTED_VAR'
+        },
+
+        {
+          key: 'VAR7',
+          value: 'value7 $VAR1',
+          raw: true
+        }
+      ]
+    end
+
+    let_it_be(:downstream_creator_user) { create(:user) }
+    let_it_be(:bridge_creator_user) { create(:user) }
+
+    let_it_be(:bridge_group) { create(:group) }
+    let_it_be(:downstream_group) { create(:group) }
+    let_it_be(:downstream_project) { create(:project, creator: downstream_creator_user, group: downstream_group) }
+    let_it_be(:project) { create(:project, :repository, :in_group, creator: bridge_creator_user, group: bridge_group) }
+    let(:bridge) { build(:ci_bridge, :playable, pipeline: pipeline, downstream: downstream_project) }
+    let!(:pipeline) { create(:ci_pipeline, project: project) }
+
+    let!(:ci_variable) do
+      create(:ci_variable,
+        project: project,
+        key: 'PROJECT_PROTECTED_VAR',
+        value: 'this is a secret',
+        protected: is_variable_protected?)
+    end
+
+    let!(:ci_group_variable) do
+      create(:ci_group_variable,
+        group: bridge_group,
+        key: 'GROUP_PROTECTED_VAR',
+        value: 'this is a secret',
+        protected: is_variable_protected?)
+    end
+
+    before do
+      bridge.yaml_variables = yaml_variables
+      allow(bridge.project).to receive(:protected_for?).and_return(true)
+    end
+
+    shared_examples 'expands variables from a project downstream' do
+      it do
+        vars = bridge.downstream_variables
+        expect(vars).to include({ key: 'EXPANDED_PROJECT_VAR6', value: 'project value6 this is a secret' })
+      end
+    end
+
+    shared_examples 'expands variables from a group downstream' do
+      it do
+        vars = bridge.downstream_variables
+        expect(vars).to include({ key: 'EXPANDED_GROUP_VAR6', value: 'group value6 this is a secret' })
+      end
+    end
+
+    shared_examples 'expands project and group variables downstream' do
+      it_behaves_like 'expands variables from a project downstream'
+
+      it_behaves_like 'expands variables from a group downstream'
+    end
+
+    shared_examples 'does not expand variables from a project downstream' do
+      it do
+        vars = bridge.downstream_variables
+        expect(vars).not_to include({ key: 'EXPANDED_PROJECT_VAR6', value: 'project value6 this is a secret' })
+      end
+    end
+
+    shared_examples 'does not expand variables from a group downstream' do
+      it do
+        vars = bridge.downstream_variables
+        expect(vars).not_to include({ key: 'EXPANDED_GROUP_VAR6', value: 'group value6 this is a secret' })
+      end
+    end
+
+    shared_examples 'feature flag is disabled' do
+      before do
+        stub_feature_flags(exclude_protected_variables_from_multi_project_pipeline_triggers: false)
+      end
+
+      it_behaves_like 'expands project and group variables downstream'
+    end
+
+    shared_examples 'does not expand project and group variables downstream' do
+      it_behaves_like 'does not expand variables from a project downstream'
+
+      it_behaves_like 'does not expand variables from a group downstream'
+    end
+
+    context 'when they are protected' do
+      let!(:is_variable_protected?) { true }
+
+      context 'and downstream project group is different from bridge group' do
+        it_behaves_like 'does not expand project and group variables downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+
+      context 'and there is no downstream project' do
+        let(:downstream_project) { nil }
+
+        it_behaves_like 'expands project and group variables downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+
+      context 'and downstream project equals bridge project' do
+        let(:downstream_project) { project }
+
+        it_behaves_like 'expands project and group variables downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+
+      context 'and downstream project group is equal to bridge project group' do
+        let_it_be(:downstream_project) { create(:project, creator: downstream_creator_user, group: bridge_group) }
+
+        it_behaves_like 'expands variables from a group downstream'
+
+        it_behaves_like 'does not expand variables from a project downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+
+      context 'and downstream project has no group' do
+        let_it_be(:downstream_project) { create(:project, creator: downstream_creator_user) }
+
+        it_behaves_like 'does not expand project and group variables downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+    end
+
+    context 'when they are not protected' do
+      let!(:is_variable_protected?) { false }
+
+      context 'and downstream project group is different from bridge group' do
+        it_behaves_like 'expands project and group variables downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+
+      context 'and there is no downstream project' do
+        let(:downstream_project) { nil }
+
+        it_behaves_like 'expands project and group variables downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+
+      context 'and downstream project equals bridge project' do
+        let(:downstream_project) { project }
+
+        it_behaves_like 'expands project and group variables downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+
+      context 'and downstream project group is equal to bridge project group' do
+        let_it_be(:downstream_project) { create(:project, creator: downstream_creator_user, group: bridge_group) }
+
+        it_behaves_like 'expands project and group variables downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+
+      context 'and downstream project has no group' do
+        let_it_be(:downstream_project) { create(:project, creator: downstream_creator_user) }
+
+        it_behaves_like 'expands project and group variables downstream'
+
+        it_behaves_like 'feature flag is disabled'
+      end
+    end
+  end
+
   describe '#forward_pipeline_variables?' do
     using RSpec::Parameterized::TableSyntax
 
-- 
GitLab