diff --git a/app/assets/javascripts/ci/job_details/components/empty_state.vue b/app/assets/javascripts/ci/job_details/components/empty_state.vue
index b536465288e5c02667669910cfe30dc686943be3..89c687c1be5ac3ac3e17e4a497359d91e5e2adc3 100644
--- a/app/assets/javascripts/ci/job_details/components/empty_state.vue
+++ b/app/assets/javascripts/ci/job_details/components/empty_state.vue
@@ -1,12 +1,15 @@
 <script>
 import { GlButton } from '@gitlab/ui';
-import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue';
+import ManualJobForm from '~/ci/job_details/components/manual_job_form.vue';
+import PipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin';
 
 export default {
   components: {
     GlButton,
-    ManualVariablesForm,
+    ManualJobForm,
   },
+  mixins: [PipelineVariablesPermissionsMixin],
+  inject: ['projectPath', 'userRole'],
   props: {
     illustrationPath: {
       type: String,
@@ -70,14 +73,20 @@ export default {
     shouldRenderManualVariables() {
       return this.playable && !this.scheduled;
     },
+    shouldRenderPipelineVariablesText() {
+      return this.canViewPipelineVariables && this.shouldRenderManualVariables && !this.isRetryable;
+    },
   },
 };
 </script>
 <template>
   <div class="gl-empty-state gl-flex gl-flex-col gl-text-center">
     <div :class="illustrationSizeClass" class="gl-max-w-full">
-      <!-- eslint-disable @gitlab/vue-require-i18n-attribute-strings -->
-      <img alt="" class="gl-max-w-full" :src="illustrationPath" />
+      <img
+        :alt="s__('CiVariables|Manual job empty state image')"
+        class="gl-max-w-full"
+        :src="illustrationPath"
+      />
     </div>
     <div class="gl-empty-state-content gl-m-auto gl-mx-auto gl-my-0 gl-p-5">
       <h2
@@ -88,13 +97,19 @@ export default {
       </h2>
       <p v-if="content" class="gl-mb-0 gl-mt-4" data-testid="job-empty-state-content">
         {{ content }}
+        <template v-if="shouldRenderPipelineVariablesText">{{
+          s__(
+            'CiVariables|You can add CI/CD variables below for last-minute configuration changes before starting the job.',
+          )
+        }}</template>
       </p>
-      <manual-variables-form
+      <manual-job-form
         v-if="shouldRenderManualVariables"
         :is-retryable="isRetryable"
         :job-id="jobId"
         :job-name="jobName"
         :confirmation-message="confirmationMessage"
+        :can-view-pipeline-variables="canViewPipelineVariables"
         @hideManualVariablesForm="$emit('hideManualVariablesForm')"
       />
       <div
diff --git a/app/assets/javascripts/ci/job_details/components/job_variables_form.vue b/app/assets/javascripts/ci/job_details/components/job_variables_form.vue
index d728f4fa3b6f506d35e3c841f37d4e64b9ed052f..57ac253303c348bd04651d779baf5f2a786cdb9f 100644
--- a/app/assets/javascripts/ci/job_details/components/job_variables_form.vue
+++ b/app/assets/javascripts/ci/job_details/components/job_variables_form.vue
@@ -170,9 +170,9 @@ export default {
         data-testid="delete-variable-btn"
         @click="deleteVariable(variable.id)"
       />
-      <!-- Placeholder button to keep the layout fixed -->
       <gl-button
         v-else
+        aria-hidden="true"
         class="gl-pointer-events-none gl-opacity-0"
         :class="$options.clearBtnSharedClasses"
         data-testid="delete-variable-btn-placeholder"
diff --git a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue b/app/assets/javascripts/ci/job_details/components/manual_job_form.vue
similarity index 94%
rename from app/assets/javascripts/ci/job_details/components/manual_variables_form.vue
rename to app/assets/javascripts/ci/job_details/components/manual_job_form.vue
index 5734882c44fe1b09a33cfbe58d8d03331063969a..2b42fd5e5e95e9750aef6986e34ec1866921424b 100644
--- a/app/assets/javascripts/ci/job_details/components/manual_variables_form.vue
+++ b/app/assets/javascripts/ci/job_details/components/manual_job_form.vue
@@ -16,7 +16,7 @@ import JobVariablesForm from './job_variables_form.vue';
 // It is meant to fetch/update the job information via GraphQL instead of REST API.
 
 export default {
-  name: 'ManualVariablesForm',
+  name: 'ManualJobForm',
   components: {
     GlButton,
     JobVariablesForm,
@@ -39,6 +39,11 @@ export default {
       required: false,
       default: null,
     },
+    canViewPipelineVariables: {
+      type: Boolean,
+      required: false,
+      default: false,
+    },
   },
   data() {
     return {
@@ -130,7 +135,11 @@ export default {
 </script>
 <template>
   <div>
-    <job-variables-form :job-id="jobId" @update-variables="onVariablesUpdate" />
+    <job-variables-form
+      v-if="canViewPipelineVariables"
+      :job-id="jobId"
+      @update-variables="onVariablesUpdate"
+    />
 
     <div class="gl-mt-5 gl-flex gl-justify-center gl-gap-x-2">
       <gl-button
diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue
index db264a499ab95b92c45b9fa10ee38080f828c6b4..03a23ab621e234ee2da9c3460c9203016e9583dc 100644
--- a/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue
+++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_sidebar_retry_button.vue
@@ -7,6 +7,7 @@ import { s__, __ } from '~/locale';
 import axios from '~/lib/utils/axios_utils';
 import { visitUrl } from '~/lib/utils/url_utility';
 import { confirmJobConfirmationMessage } from '~/ci/pipeline_details/graph/utils';
+import PipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin';
 
 export default {
   name: 'JobSidebarRetryButton',
@@ -20,6 +21,8 @@ export default {
     GlModal: GlModalDirective,
     GlTooltip: GlTooltipDirective,
   },
+  mixins: [PipelineVariablesPermissionsMixin],
+  inject: ['projectPath', 'userRole'],
   props: {
     modalId: {
       type: String,
@@ -93,6 +96,7 @@ export default {
   />
   <div v-else-if="isManualJob" class="gl-flex gl-gap-3">
     <gl-button
+      v-if="canViewPipelineVariables"
       v-gl-tooltip.bottom
       :title="$options.i18n.updateVariables"
       :aria-label="$options.i18n.updateVariables"
diff --git a/app/assets/javascripts/ci/job_details/index.js b/app/assets/javascripts/ci/job_details/index.js
index 7e14572b71740a05cba1d896de2658973513826d..ba35365bee85c319665be5159af05dbc31932605 100644
--- a/app/assets/javascripts/ci/job_details/index.js
+++ b/app/assets/javascripts/ci/job_details/index.js
@@ -34,6 +34,7 @@ export const initJobDetails = () => {
     pipelineTestReportUrl,
     logViewerPath,
     duoFeaturesEnabled,
+    userRole,
   } = el.dataset;
 
   const fullScreenAPIAvailable = document.fullscreenEnabled;
@@ -58,6 +59,7 @@ export const initJobDetails = () => {
       aiRootCauseAnalysisAvailable: parseBoolean(aiRootCauseAnalysisAvailable),
       duoFeaturesEnabled: parseBoolean(duoFeaturesEnabled),
       pipelineTestReportUrl,
+      userRole: userRole?.toLowerCase(),
     },
     render(h) {
       return h(JobApp, {
diff --git a/app/assets/javascripts/ci/mixins/pipeline_variables_permissions_mixin.js b/app/assets/javascripts/ci/mixins/pipeline_variables_permissions_mixin.js
new file mode 100644
index 0000000000000000000000000000000000000000..965e4fe42c67bc2f1c73b09b2cb7777c2a44a7a9
--- /dev/null
+++ b/app/assets/javascripts/ci/mixins/pipeline_variables_permissions_mixin.js
@@ -0,0 +1,62 @@
+import { s__ } from '~/locale';
+import { createAlert } from '~/alert';
+import getPipelineVariablesMinimumOverrideRoleQuery from '~/ci/pipeline_variables_minimum_override_role/graphql/queries/get_pipeline_variables_minimum_override_role_project_setting.query.graphql';
+
+const ROLE_NO_ONE = 'no_one_allowed';
+const ROLE_DEVELOPER = 'developer';
+const ROLE_MAINTAINER = 'maintainer';
+const ROLE_OWNER = 'owner';
+
+export default {
+  USER_ROLES: Object.freeze([ROLE_DEVELOPER, ROLE_MAINTAINER, ROLE_OWNER]),
+
+  inject: ['projectPath', 'userRole'],
+
+  data() {
+    return {
+      hasError: false,
+      pipelineVariablesSettings: {},
+    };
+  },
+
+  apollo: {
+    pipelineVariablesSettings: {
+      query: getPipelineVariablesMinimumOverrideRoleQuery,
+      variables() {
+        return {
+          fullPath: this.projectPath,
+        };
+      },
+      update({ project }) {
+        return project?.ciCdSettings || {};
+      },
+      error() {
+        this.hasError = true;
+        createAlert({
+          message: s__('CiVariables|There was a problem fetching the CI/CD settings.'),
+        });
+      },
+    },
+  },
+
+  computed: {
+    pipelineVariablesPermissionsLoading() {
+      return this.$apollo.queries.pipelineVariablesSettings.loading;
+    },
+    minimumRole() {
+      return this.pipelineVariablesSettings?.pipelineVariablesMinimumOverrideRole;
+    },
+    canViewPipelineVariables() {
+      if (this.pipelineVariablesPermissionsLoading) return false;
+
+      if (this.minimumRole === ROLE_NO_ONE || this.hasError) {
+        return false;
+      }
+
+      const userRoleIndex = this.$options.USER_ROLES.indexOf(this.userRole);
+      const minRoleIndex = this.$options.USER_ROLES.indexOf(this.minimumRole);
+
+      return userRoleIndex >= minRoleIndex || false;
+    },
+  },
+};
diff --git a/app/helpers/ci/jobs_helper.rb b/app/helpers/ci/jobs_helper.rb
index 6c755f8fe96a5ffc90aa760ac1fe5457d8afbadc..2080d5264f6a4deb3616336b49a821420e01f5f3 100644
--- a/app/helpers/ci/jobs_helper.rb
+++ b/app/helpers/ci/jobs_helper.rb
@@ -14,7 +14,8 @@ def jobs_data(project, build)
         "runner_settings_url" => project_runners_path(build.project, anchor: 'js-runners-settings'),
         "retry_outdated_job_docs_url" => help_page_path('ci/pipelines/settings.md', anchor: 'prevent-outdated-deployment-jobs'),
         "pipeline_test_report_url" => test_report_project_pipeline_path(project, build.pipeline),
-        "log_viewer_path" => viewer_project_job_path(project, build)
+        "log_viewer_path" => viewer_project_job_path(project, build),
+        "user_role" => project.team.human_max_access(current_user&.id)
       }
     end
 
diff --git a/ee/spec/lib/ee/gitlab/ci/status/build/manual_spec.rb b/ee/spec/lib/ee/gitlab/ci/status/build/manual_spec.rb
index ca41bc1deeb504971c57b4b0e38e023f1667d576..b91603053cfc003cf132d11af950036bc8870d65 100644
--- a/ee/spec/lib/ee/gitlab/ci/status/build/manual_spec.rb
+++ b/ee/spec/lib/ee/gitlab/ci/status/build/manual_spec.rb
@@ -44,10 +44,7 @@
 
         it 'instructs the user about possible actions' do
           expect(illustration[:content]).to eq(
-            _(
-              'This job does not start automatically and must be started manually. ' \
-              'You can add CI/CD variables below for last-minute configuration changes before starting the job.'
-            )
+            _('This job does not start automatically and must be started manually.')
           )
         end
       end
diff --git a/lib/gitlab/ci/status/build/manual.rb b/lib/gitlab/ci/status/build/manual.rb
index 0e16084d49173ff0441c4e0d508d7535e3fd15b0..16782c94e3435766157587ffb4d55f533bd58d4b 100644
--- a/lib/gitlab/ci/status/build/manual.rb
+++ b/lib/gitlab/ci/status/build/manual.rb
@@ -32,7 +32,7 @@ def manual_job_action_message
             if subject.retryable?
               _("You can modify this job's CI/CD variables before running it again.")
             else
-              _('This job does not start automatically and must be started manually. You can add CI/CD variables below for last-minute configuration changes before starting the job.')
+              _('This job does not start automatically and must be started manually.')
             end
           end
 
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 73e85a0792bda57c0c80a818233879a12a9b612c..afde06fb801057795db240b75bde9e11cc4fa998 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -12486,6 +12486,9 @@ msgstr ""
 msgid "CiVariables|Learn how to %{linkStart}restrict CI/CD variables to specific environments%{linkEnd} for better security."
 msgstr ""
 
+msgid "CiVariables|Manual job empty state image"
+msgstr ""
+
 msgid "CiVariables|Masked"
 msgstr ""
 
@@ -12576,6 +12579,9 @@ msgstr ""
 msgid "CiVariables|The value must have %{charsAmount} characters."
 msgstr ""
 
+msgid "CiVariables|There was a problem fetching the CI/CD settings."
+msgstr ""
+
 msgid "CiVariables|There was a problem fetching the pipeline variables default role."
 msgstr ""
 
@@ -12627,6 +12633,9 @@ msgstr ""
 msgid "CiVariables|What are pipeline variables?"
 msgstr ""
 
+msgid "CiVariables|You can add CI/CD variables below for last-minute configuration changes before starting the job."
+msgstr ""
+
 msgid "CiVariables|You can use CI/CD variables with the same name in different places, but the variables might overwrite each other. %{linkStart}What is the order of precedence for variables?%{linkEnd}"
 msgstr ""
 
@@ -59458,7 +59467,7 @@ msgstr ""
 msgid "This job does not run automatically and must be started manually, but you do not have access to it."
 msgstr ""
 
-msgid "This job does not start automatically and must be started manually. You can add CI/CD variables below for last-minute configuration changes before starting the job."
+msgid "This job does not start automatically and must be started manually."
 msgstr ""
 
 msgid "This job has been canceled"
diff --git a/spec/frontend/ci/job_details/components/empty_state_spec.js b/spec/frontend/ci/job_details/components/empty_state_spec.js
index 0c2e07526f1e703422b8959435788d9c6b7d17ab..2cb724095c05e6fe156ef760456f9983d78ea3db 100644
--- a/spec/frontend/ci/job_details/components/empty_state_spec.js
+++ b/spec/frontend/ci/job_details/components/empty_state_spec.js
@@ -1,7 +1,7 @@
 import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
 import EmptyState from '~/ci/job_details/components/empty_state.vue';
-import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue';
-import { mockFullPath, mockId } from '../mock_data';
+import ManualJobForm from '~/ci/job_details/components/manual_job_form.vue';
+import { mockFullPath, mockId, mockPipelineVariablesPermissions } from '../mock_data';
 
 describe('Empty State', () => {
   let wrapper;
@@ -10,20 +10,30 @@ describe('Empty State', () => {
     illustrationPath: 'illustrations/empty-state/empty-job-pending-md.svg',
     illustrationSizeClass: '',
     jobId: mockId,
+    jobName: 'My job',
     title: 'This job has not started yet',
     playable: false,
     isRetryable: true,
   };
 
-  const createWrapper = (props) => {
+  const defaultProvide = {
+    projectPath: mockFullPath,
+    userRole: 'maintainer',
+  };
+
+  const createWrapper = ({
+    props,
+    pipelineVariablesPermissionsMixin = mockPipelineVariablesPermissions(true),
+  } = {}) => {
     wrapper = shallowMountExtended(EmptyState, {
       propsData: {
         ...defaultProps,
         ...props,
       },
       provide: {
-        projectPath: mockFullPath,
+        ...defaultProvide,
       },
+      mixins: [pipelineVariablesPermissionsMixin],
     });
   };
 
@@ -33,7 +43,7 @@ describe('Empty State', () => {
   const findTitle = () => wrapper.findByTestId('job-empty-state-title');
   const findContent = () => wrapper.findByTestId('job-empty-state-content');
   const findAction = () => wrapper.findByTestId('job-empty-state-action');
-  const findManualVarsForm = () => wrapper.findComponent(ManualVariablesForm);
+  const findManualVarsForm = () => wrapper.findComponent(ManualJobForm);
 
   describe('renders image and title', () => {
     beforeEach(() => {
@@ -44,6 +54,10 @@ describe('Empty State', () => {
       expect(findEmptyStateImage().exists()).toBe(true);
     });
 
+    it('renders alt text', () => {
+      expect(findEmptyStateImage().attributes('alt')).toBe('Manual job empty state image');
+    });
+
     it('renders provided title', () => {
       expect(findTitle().text().trim()).toBe(defaultProps.title);
     });
@@ -51,7 +65,7 @@ describe('Empty State', () => {
 
   describe('with content', () => {
     beforeEach(() => {
-      createWrapper({ content });
+      createWrapper({ props: { content } });
     });
 
     it('renders content', () => {
@@ -72,10 +86,12 @@ describe('Empty State', () => {
   describe('with action', () => {
     beforeEach(() => {
       createWrapper({
-        action: {
-          path: 'runner',
-          button_title: 'Check runner',
-          method: 'post',
+        props: {
+          action: {
+            path: 'runner',
+            button_title: 'Check runner',
+            method: 'post',
+          },
         },
       });
     });
@@ -88,7 +104,9 @@ describe('Empty State', () => {
   describe('without action', () => {
     beforeEach(() => {
       createWrapper({
-        action: null,
+        props: {
+          action: null,
+        },
       });
     });
 
@@ -104,13 +122,15 @@ describe('Empty State', () => {
   describe('with playable action and not scheduled job', () => {
     beforeEach(() => {
       createWrapper({
-        content,
-        playable: true,
-        scheduled: false,
-        action: {
-          path: 'runner',
-          button_title: 'Check runner',
-          method: 'post',
+        props: {
+          content,
+          playable: true,
+          scheduled: false,
+          action: {
+            path: 'runner',
+            button_title: 'Check runner',
+            method: 'post',
+          },
         },
       });
     });
@@ -127,14 +147,71 @@ describe('Empty State', () => {
   describe('with playable action and scheduled job', () => {
     beforeEach(() => {
       createWrapper({
-        playable: true,
-        scheduled: true,
-        content,
+        props: {
+          playable: true,
+          scheduled: true,
+          content,
+        },
+      });
+    });
+
+    it('does not render manual variables form', () => {
+      expect(findManualVarsForm().exists()).toBe(false);
+    });
+  });
+
+  describe('when user is allowed to see the pipeline variables', () => {
+    beforeEach(() => {
+      createWrapper({
+        props: { content, isRetryable: false, playable: true, scheduled: false },
+      });
+    });
+
+    it('provides `canViewPipelineVariables` as `true` to manual variables form', () => {
+      expect(findManualVarsForm().props('canViewPipelineVariables')).toBe(true);
+    });
+
+    it('renders additional text for pipeline variables when it is not a retryable job', () => {
+      expect(findContent().text()).toContain(
+        'You can add CI/CD variables below for last-minute configuration changes before starting the job.',
+      );
+    });
+  });
+
+  describe('when user is not allowed to see the pipeline variables', () => {
+    beforeEach(() => {
+      createWrapper({
+        props: { content, isRetryable: false, playable: true, scheduled: false },
+        pipelineVariablesPermissionsMixin: mockPipelineVariablesPermissions(false),
+      });
+    });
+
+    it('provides `canViewPipelineVariables` as `false` to manual variables form', () => {
+      expect(findManualVarsForm().props('canViewPipelineVariables')).toBe(false);
+    });
+
+    it('does not render additional text for pipeline variables when it is not a retryable job', () => {
+      expect(findContent().text()).not.toContain(
+        'You can add CI/CD variables below for last-minute configuration changes before starting the job.',
+      );
+    });
+  });
+
+  describe('when user is not allowed to retry the pipeline', () => {
+    beforeEach(() => {
+      createWrapper({
+        props: { content, isRetryable: false },
       });
     });
 
     it('does not render manual variables form', () => {
       expect(findManualVarsForm().exists()).toBe(false);
     });
+
+    it('does not render additional text for pipeline variables when it is not a retryable job', () => {
+      expect(findContent().text()).not.toContain(
+        'You can add CI/CD variables below for last-minute configuration changes before starting the job.',
+      );
+    });
   });
 });
diff --git a/spec/frontend/ci/job_details/components/manual_variables_form_spec.js b/spec/frontend/ci/job_details/components/manual_job_form_spec.js
similarity index 92%
rename from spec/frontend/ci/job_details/components/manual_variables_form_spec.js
rename to spec/frontend/ci/job_details/components/manual_job_form_spec.js
index 046ebd194a25adfe857da3d22ca419075dcbaee4..84e280a5d14fa3c5ee076087176dcd55a709f139 100644
--- a/spec/frontend/ci/job_details/components/manual_variables_form_spec.js
+++ b/spec/frontend/ci/job_details/components/manual_job_form_spec.js
@@ -8,7 +8,7 @@ import { JOB_GRAPHQL_ERRORS } from '~/ci/constants';
 import { convertToGraphQLId } from '~/graphql_shared/utils';
 import waitForPromises from 'helpers/wait_for_promises';
 import { visitUrl } from '~/lib/utils/url_utility';
-import ManualVariablesForm from '~/ci/job_details/components/manual_variables_form.vue';
+import ManualJobForm from '~/ci/job_details/components/manual_job_form.vue';
 import playJobMutation from '~/ci/job_details/graphql/mutations/job_play_with_variables.mutation.graphql';
 import retryJobMutation from '~/ci/job_details/graphql/mutations/job_retry_with_variables.mutation.graphql';
 import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
@@ -30,6 +30,13 @@ jest.mock('~/lib/utils/url_utility', () => ({
   visitUrl: jest.fn(),
 }));
 
+const defaultProps = {
+  jobId: mockId,
+  jobName: 'job-name',
+  isRetryable: false,
+  canViewPipelineVariables: true,
+};
+
 const defaultProvide = {
   projectPath: mockFullPath,
 };
@@ -59,11 +66,9 @@ describe('Manual Variables Form', () => {
       apolloProvider: mockApollo,
     };
 
-    wrapper = shallowMountExtended(ManualVariablesForm, {
+    wrapper = shallowMountExtended(ManualJobForm, {
       propsData: {
-        jobId: mockId,
-        jobName: 'job-name',
-        isRetryable: false,
+        ...defaultProps,
         ...props,
       },
       provide: {
@@ -253,4 +258,16 @@ describe('Manual Variables Form', () => {
       });
     });
   });
+
+  describe('when the user is not allowed to see the pipeline variables', () => {
+    beforeEach(() => {
+      createComponent({
+        props: { canViewPipelineVariables: false },
+      });
+    });
+
+    it('does not render job variables form', () => {
+      expect(findVariablesForm().exists()).toBe(false);
+    });
+  });
 });
diff --git a/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js b/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js
index 0d51695b8fbd4cdf358d8df30df97782e583d992..7d4a282a8bd395184238aa09258054abe2682f65 100644
--- a/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js
+++ b/spec/frontend/ci/job_details/components/sidebar/job_sidebar_retry_button_spec.js
@@ -6,9 +6,15 @@ import job from 'jest/ci/jobs_mock_data';
 import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
 import axios from '~/lib/utils/axios_utils';
 import waitForPromises from 'helpers/wait_for_promises';
+import { mockFullPath, mockPipelineVariablesPermissions } from '../../mock_data';
 
 jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_action');
 
+const defaultProvide = {
+  projectPath: mockFullPath,
+  userRole: 'maintainer',
+};
+
 describe('Job Sidebar Retry Button', () => {
   let store;
   let wrapper;
@@ -19,9 +25,13 @@ describe('Job Sidebar Retry Button', () => {
   const findManualRetryButton = () => wrapper.findByTestId('manual-run-again-btn');
   const findManualRunEditButton = () => wrapper.findByTestId('manual-run-edit-btn');
 
-  const createWrapper = ({ props = {} } = {}) => {
+  const createWrapper = ({
+    mountFn = shallowMountExtended,
+    props = {},
+    pipelineVariablesPermissionsMixin = mockPipelineVariablesPermissions(true),
+  } = {}) => {
     store = createStore();
-    wrapper = shallowMountExtended(JobsSidebarRetryButton, {
+    wrapper = mountFn(JobsSidebarRetryButton, {
       propsData: {
         href: job.retry_path,
         isManualJob: false,
@@ -30,13 +40,23 @@ describe('Job Sidebar Retry Button', () => {
         confirmationMessage: null,
         ...props,
       },
+      provide: {
+        ...defaultProvide,
+      },
+      mixins: [pipelineVariablesPermissionsMixin],
       store,
     });
   };
 
-  beforeEach(() => {
-    createWrapper();
-  });
+  const createWrapperWithConfirmation = () => {
+    createWrapper({
+      mountFn: mountExtended,
+      props: {
+        isManualJob: true,
+        confirmationMessage: 'Are you sure?',
+      },
+    });
+  };
 
   it.each([
     [null, false, true],
@@ -45,6 +65,7 @@ describe('Job Sidebar Retry Button', () => {
   ])(
     'when error is: %s, should render button: %s | should render link: %s',
     async (failureReason, buttonExists, linkExists) => {
+      createWrapper();
       await store.dispatch('receiveJobSuccess', { ...job, failure_reason: failureReason });
 
       expect(findRetryButton().exists()).toBe(buttonExists);
@@ -54,6 +75,8 @@ describe('Job Sidebar Retry Button', () => {
 
   describe('Button', () => {
     it('should have the correct configuration', async () => {
+      createWrapper();
+
       await store.dispatch('receiveJobSuccess', { failure_reason: forwardDeploymentFailure });
       expect(findRetryButton().attributes()).toMatchObject({
         category: 'primary',
@@ -65,6 +88,8 @@ describe('Job Sidebar Retry Button', () => {
 
   describe('Link', () => {
     it('should have the correct configuration', () => {
+      createWrapper();
+
       expect(findRetryLink().attributes()).toMatchObject({
         'data-method': 'post',
         href: job.retry_path,
@@ -74,20 +99,8 @@ describe('Job Sidebar Retry Button', () => {
   });
 
   describe('confirmationMessage', () => {
-    const createWrapperWithConfirmation = () => {
-      wrapper = mountExtended(JobsSidebarRetryButton, {
-        propsData: {
-          href: job.retry_path,
-          modalId: 'modal-id',
-          jobName: job.name,
-          isManualJob: true,
-          confirmationMessage: 'Are you sure?',
-        },
-        store,
-      });
-    };
-
     it('should not render confirmation modal if confirmation message is null', () => {
+      createWrapper();
       findRetryLink().trigger('click');
       expect(confirmAction).not.toHaveBeenCalled();
     });
@@ -125,4 +138,21 @@ describe('Job Sidebar Retry Button', () => {
       expect(mock.history.post[0].url).toBe(job.retry_path);
     });
   });
+
+  describe('manual job retry with update variables button', () => {
+    it('is rendered if user is allowed to view pipeline variables', async () => {
+      createWrapper({ props: { isManualJob: true } });
+      await waitForPromises();
+      expect(findManualRunEditButton().exists()).toBe(true);
+    });
+
+    it('is not rendered if user is not allowed to view pipeline variables', async () => {
+      createWrapper({
+        props: { isManualJob: true },
+        pipelineVariablesPermissionsMixin: mockPipelineVariablesPermissions(false),
+      });
+      await waitForPromises();
+      expect(findManualRunEditButton().exists()).toBe(false);
+    });
+  });
 });
diff --git a/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js
index cce39c51b010aa658611f3cffa8577731a26d09f..584b75792ecc5787b163acae0d44214950fa28bf 100644
--- a/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js
+++ b/spec/frontend/ci/job_details/components/sidebar/sidebar_header_spec.js
@@ -23,6 +23,7 @@ describe('Sidebar Header', () => {
         ...props,
         jobId: mockId,
         restJob: {
+          name: 'My job',
           status: {
             action: {
               confirmation_message: null,
diff --git a/spec/frontend/ci/job_details/mock_data.js b/spec/frontend/ci/job_details/mock_data.js
index c1527f6f3bcce550dd86266c5d219f8b3cd069ea..3e98940ad59c038102b38aee0f97dc29785d639c 100644
--- a/spec/frontend/ci/job_details/mock_data.js
+++ b/spec/frontend/ci/job_details/mock_data.js
@@ -122,3 +122,11 @@ export const mockPendingJobData = {
     },
   },
 };
+
+export const mockPipelineVariablesPermissions = (value) => ({
+  computed: {
+    canViewPipelineVariables() {
+      return value;
+    },
+  },
+});
diff --git a/spec/frontend/ci/mixins/pipeline_variables_permissions_mixin_spec.js b/spec/frontend/ci/mixins/pipeline_variables_permissions_mixin_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..50b71c6a87a59ab1e3ebfd6fa1ad35ebe30e4134
--- /dev/null
+++ b/spec/frontend/ci/mixins/pipeline_variables_permissions_mixin_spec.js
@@ -0,0 +1,132 @@
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/alert';
+import pipelineVariablesPermissionsMixin from '~/ci/mixins/pipeline_variables_permissions_mixin';
+import getPipelineVariablesMinimumOverrideRoleQuery from '~/ci/pipeline_variables_minimum_override_role/graphql/queries/get_pipeline_variables_minimum_override_role_project_setting.query.graphql';
+
+Vue.use(VueApollo);
+jest.mock('~/alert');
+
+const TestComponent = {
+  mixins: [pipelineVariablesPermissionsMixin],
+  template: `
+    <div>
+      <div v-if="pipelineVariablesPermissionsLoading" data-testid="loading-state">Loading...</div>
+      <div v-else-if="canViewPipelineVariables" data-testid="authorized-content">Authorized</div>
+      <div v-else data-testid="unauthorized-content">Unauthorized</div>
+      <div v-if="hasError" data-testid="error-state">Error occurred</div>
+    </div>
+  `,
+};
+
+describe('Pipeline Variables Permissions Mixin', () => {
+  let wrapper;
+  let minimumRoleHandler;
+
+  const ROLE_NO_ONE = 'no_one_allowed';
+  const ROLE_DEVELOPER = 'developer';
+  const ROLE_MAINTAINER = 'maintainer';
+  const ROLE_OWNER = 'owner';
+
+  const defaultProvide = {
+    userRole: ROLE_DEVELOPER,
+    projectPath: 'project/path',
+  };
+
+  const generateSettingsResponse = (minimumRole = ROLE_DEVELOPER) => ({
+    data: {
+      project: {
+        id: 'gid://gitlab/Project/12',
+        ciCdSettings: {
+          pipelineVariablesMinimumOverrideRole: minimumRole,
+        },
+      },
+    },
+  });
+
+  const createComponent = async ({ provide = {} } = {}) => {
+    const handlers = [[getPipelineVariablesMinimumOverrideRoleQuery, minimumRoleHandler]];
+
+    wrapper = shallowMountExtended(TestComponent, {
+      apolloProvider: createMockApollo(handlers),
+      provide: {
+        ...defaultProvide,
+        ...provide,
+      },
+    });
+
+    await waitForPromises();
+  };
+
+  const findLoadingState = () => wrapper.findByTestId('loading-state');
+  const findAuthorizedContent = () => wrapper.findByTestId('authorized-content');
+  const findUnauthorizedContent = () => wrapper.findByTestId('unauthorized-content');
+  const findErrorState = () => wrapper.findByTestId('error-state');
+
+  describe('on load', () => {
+    describe('when settings query is successful', () => {
+      beforeEach(async () => {
+        minimumRoleHandler = jest.fn().mockResolvedValue(generateSettingsResponse());
+        await createComponent();
+      });
+
+      it('fetches data from settings query', () => {
+        expect(minimumRoleHandler).toHaveBeenCalledTimes(1);
+      });
+    });
+
+    describe('when settings query fails', () => {
+      beforeEach(async () => {
+        minimumRoleHandler = jest.fn().mockRejectedValue(new Error('GraphQL error'));
+        await createComponent();
+      });
+
+      it('calls createAlert with the correct message', () => {
+        expect(createAlert).toHaveBeenCalled();
+        expect(createAlert).toHaveBeenCalledWith({
+          message: 'There was a problem fetching the CI/CD settings.',
+        });
+      });
+
+      it('shows error state', () => {
+        expect(findErrorState().exists()).toBe(true);
+      });
+    });
+  });
+
+  describe('during loading state', () => {
+    it('shows loading state and not content', async () => {
+      minimumRoleHandler = jest.fn().mockImplementation(() => new Promise(() => {}));
+      await createComponent();
+
+      expect(findLoadingState().exists()).toBe(true);
+      expect(findAuthorizedContent().exists()).toBe(false);
+      expect(findUnauthorizedContent().exists()).toBe(false);
+    });
+  });
+
+  describe('permissions calculations based on user roles', () => {
+    it.each`
+      scenario                                   | userRole           | minimumRole        | isAuthorized
+      ${'user role is lower than minimum role'}  | ${ROLE_DEVELOPER}  | ${ROLE_MAINTAINER} | ${false}
+      ${'user role is equal to minimum role'}    | ${ROLE_MAINTAINER} | ${ROLE_MAINTAINER} | ${true}
+      ${'user role is higher than minimum role'} | ${ROLE_OWNER}      | ${ROLE_MAINTAINER} | ${true}
+      ${'minimum role is no_one_allowed'}        | ${ROLE_OWNER}      | ${ROLE_NO_ONE}     | ${false}
+    `(
+      'when $scenario, authorization is $isAuthorized',
+      async ({ userRole, minimumRole, isAuthorized }) => {
+        minimumRoleHandler = jest.fn().mockResolvedValue(generateSettingsResponse(minimumRole));
+
+        await createComponent({
+          provide: { userRole },
+        });
+
+        expect(findAuthorizedContent().exists()).toBe(isAuthorized);
+        expect(findUnauthorizedContent().exists()).toBe(!isAuthorized);
+      },
+    );
+  });
+});
diff --git a/spec/helpers/ci/jobs_helper_spec.rb b/spec/helpers/ci/jobs_helper_spec.rb
index 1373f8767b4d9c1e695afd021056e143d26366bd..d7619ea98c66a3c8500d2969004ce766a15dafdd 100644
--- a/spec/helpers/ci/jobs_helper_spec.rb
+++ b/spec/helpers/ci/jobs_helper_spec.rb
@@ -9,6 +9,10 @@
     let_it_be(:user) { create(:user) }
     let_it_be(:report) { create(:ci_build_report_result, build: job, project: project) }
 
+    before_all do
+      project.add_maintainer(user)
+    end
+
     before do
       helper.instance_variable_set(:@project, project)
       helper.instance_variable_set(:@build, job)
@@ -30,7 +34,8 @@
         "runner_settings_url" => "/#{project.full_path}/-/runners#js-runners-settings",
         "retry_outdated_job_docs_url" => "/help/ci/pipelines/settings.md#prevent-outdated-deployment-jobs",
         "pipeline_test_report_url" => "/#{project.full_path}/-/pipelines/#{job.pipeline.id}/test_report",
-        "log_viewer_path" => "/#{project.full_path}/-/jobs/#{job.id}/viewer"
+        "log_viewer_path" => "/#{project.full_path}/-/jobs/#{job.id}/viewer",
+        "user_role" => "Maintainer"
       })
     end
 
diff --git a/spec/lib/gitlab/ci/status/build/manual_spec.rb b/spec/lib/gitlab/ci/status/build/manual_spec.rb
index 20915f06a8fe738df92c419bef6905ab4cb8cd37..ac9da81f368711e336ca17be426ab6645a611b35 100644
--- a/spec/lib/gitlab/ci/status/build/manual_spec.rb
+++ b/spec/lib/gitlab/ci/status/build/manual_spec.rb
@@ -21,10 +21,7 @@
       context 'when the job has not been played' do
         it 'instructs the user about possible actions' do
           expect(subject.illustration[:content]).to eq(
-            _(
-              'This job does not start automatically and must be started manually. ' \
-              'You can add CI/CD variables below for last-minute configuration changes before starting the job.'
-            )
+            _('This job does not start automatically and must be started manually.')
           )
         end
       end