diff --git a/app/controllers/projects/deploy_tokens_controller.rb b/app/controllers/projects/deploy_tokens_controller.rb
index ed77fa2fee617bdbeaa61678a2d6584087f81ba6..7ee9b271a1cb6af5ff87f42c0978c83c8e4003a5 100644
--- a/app/controllers/projects/deploy_tokens_controller.rb
+++ b/app/controllers/projects/deploy_tokens_controller.rb
@@ -1,7 +1,7 @@
 # frozen_string_literal: true
 
 class Projects::DeployTokensController < Projects::ApplicationController
-  before_action :authorize_admin_project!
+  before_action :authorize_destroy_deploy_token!
 
   feature_category :continuous_delivery
   urgency :low
diff --git a/app/controllers/projects/settings/repository_controller.rb b/app/controllers/projects/settings/repository_controller.rb
index 8dc30ed5f68c46b1cdb2cb1eed396bf980997168..2d7a3d698f03ac6469982f93ab1cec66c6da4b68 100644
--- a/app/controllers/projects/settings/repository_controller.rb
+++ b/app/controllers/projects/settings/repository_controller.rb
@@ -4,8 +4,7 @@ module Projects
   module Settings
     class RepositoryController < Projects::ApplicationController
       layout 'project_settings'
-      before_action :authorize_admin_project!, except: [:show, :update]
-      before_action :authorize_admin_push_rules!, only: [:show, :update]
+      before_action :authorize_admin_project!
       before_action :define_variables, only: [:create_deploy_token]
 
       before_action do
diff --git a/app/helpers/deploy_tokens_helper.rb b/app/helpers/deploy_tokens_helper.rb
index 597823cdac71658e078ba87af4f71e95048337dd..cd6eb7b2dab8549c31d0e1ccd97624fa86168b09 100644
--- a/app/helpers/deploy_tokens_helper.rb
+++ b/app/helpers/deploy_tokens_helper.rb
@@ -8,13 +8,17 @@ def expand_deploy_tokens_section?(new_deploy_token, created_deploy_token)
   end
 
   def container_registry_enabled?(group_or_project)
-    Gitlab.config.registry.enabled &&
-      can?(current_user, :read_container_image, group_or_project)
+    return false unless ::Gitlab.config.registry.enabled
+
+    can?(current_user, :read_container_image, group_or_project) ||
+      can?(current_user, :manage_deploy_tokens, group_or_project)
   end
 
   def packages_registry_enabled?(group_or_project)
-    Gitlab.config.packages.enabled &&
-      can?(current_user, :read_package, group_or_project&.packages_policy_subject)
+    return false unless ::Gitlab.config.packages.enabled
+
+    can?(current_user, :read_package, group_or_project&.packages_policy_subject) ||
+      can?(current_user, :manage_deploy_tokens, group_or_project)
   end
 
   def deploy_token_revoke_button_data(token:, group_or_project:)
diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb
index 7ce72a8f7313737438c0dd81a1a21ec5082a776d..badbd5761e39b54dbe1fc5e8285ac6e322d196ef 100644
--- a/app/policies/project_policy.rb
+++ b/app/policies/project_policy.rb
@@ -605,6 +605,7 @@ class ProjectPolicy < BasePolicy
     enable :read_import_error
     enable :admin_cicd_variables
     enable :admin_push_rules
+    enable :manage_deploy_tokens
   end
 
   rule { can?(:admin_build) }.enable :manage_trigger
diff --git a/app/validators/json_schemas/member_role_permissions.json b/app/validators/json_schemas/member_role_permissions.json
index 8b75568ce8132ebe419d3674fe26f3cbf7bac648..a2d45d354fb10f7b966533a4674ba16a17252c70 100644
--- a/app/validators/json_schemas/member_role_permissions.json
+++ b/app/validators/json_schemas/member_role_permissions.json
@@ -31,6 +31,9 @@
     "archive_project": {
       "type": "boolean"
     },
+    "manage_deploy_tokens": {
+      "type": "boolean"
+    },
     "manage_group_access_tokens": {
       "type": "boolean"
     },
diff --git a/app/views/groups/settings/repository/show.html.haml b/app/views/groups/settings/repository/show.html.haml
index fc81d22391af6b58f4b0642da8705699d1615234..6b95189fbb6507e889addbc57818e009a928ca86 100644
--- a/app/views/groups/settings/repository/show.html.haml
+++ b/app/views/groups/settings/repository/show.html.haml
@@ -2,13 +2,13 @@
 - page_title _('Repository')
 - @force_desktop_expanded_sidebar = true
 
-- if can?(current_user, :admin_group, @group)
+- if can?(current_user, :create_deploy_token, @group)
   - deploy_token_description = s_('DeployTokens|Group deploy tokens allow access to the packages, repositories, and registry images within the group.')
-
   = render "shared/deploy_tokens/index", group_or_project: @group, description: deploy_token_description
-  = render "default_branch", group: @group
 
-= render_if_exists "protected_branches/protected_branches", protected_branch_entity: @group
+- if can?(current_user, :admin_group, @group)
+  = render "default_branch", group: @group
+  = render_if_exists "protected_branches/protected_branches", protected_branch_entity: @group
 
 - if can?(current_user, :change_push_rules, @group)
   = render "push_rules"
diff --git a/app/views/projects/settings/repository/show.html.haml b/app/views/projects/settings/repository/show.html.haml
index f0ca2a18e5b3a2190aa49049966c041a82bd68b8..dede437d2b35a0367cb1de5bfc66accb2fccac27 100644
--- a/app/views/projects/settings/repository/show.html.haml
+++ b/app/views/projects/settings/repository/show.html.haml
@@ -18,7 +18,11 @@
   -# Those are used throughout the actual views. These `shared` views are then
   -# reused in EE.
   = render "projects/settings/repository/protected_branches", protected_branch_entity: @project
+
+- if current_user.can?(:manage_deploy_tokens, @project)
   = render "shared/deploy_tokens/index", group_or_project: @project, description: deploy_token_description
+
+- if can?(current_user, :admin_project, @project)
   = render 'shared/deploy_keys/index'
   = render "projects/maintenance/show"
 
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 561f34b80461766e173320e7e000fd1e8ef4929e..f8c3654d74fe9640a25720fa28573a3e6d6f2577 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -33810,6 +33810,7 @@ Member role permission.
 | <a id="memberrolepermissionadmin_vulnerability"></a>`ADMIN_VULNERABILITY` | Edit the vulnerability object, including the status and linking an issue. Includes the `read_vulnerability` permission actions. |
 | <a id="memberrolepermissionadmin_web_hook"></a>`ADMIN_WEB_HOOK` | Manage webhooks. |
 | <a id="memberrolepermissionarchive_project"></a>`ARCHIVE_PROJECT` | Allows archiving of projects. |
+| <a id="memberrolepermissionmanage_deploy_tokens"></a>`MANAGE_DEPLOY_TOKENS` | Manage deploy tokens at the group or project level. |
 | <a id="memberrolepermissionmanage_group_access_tokens"></a>`MANAGE_GROUP_ACCESS_TOKENS` | Create, read, update, and delete group access tokens. When creating a token, users with this custom permission must select a role for that token that has the same or fewer permissions as the default role used as the base for the custom role. |
 | <a id="memberrolepermissionmanage_project_access_tokens"></a>`MANAGE_PROJECT_ACCESS_TOKENS` | Create, read, update, and delete project access tokens. When creating a token, users with this custom permission must select a role for that token that has the same or fewer permissions as the default role used as the base for the custom role. |
 | <a id="memberrolepermissionmanage_security_policy_link"></a>`MANAGE_SECURITY_POLICY_LINK` | Allows linking security policy projects. |
diff --git a/doc/user/custom_roles/abilities.md b/doc/user/custom_roles/abilities.md
index c19192be819d785a9110af91642ddb6b676337f3..a3cfb1a653084e1ca7b6a61865f48910b1122760 100644
--- a/doc/user/custom_roles/abilities.md
+++ b/doc/user/custom_roles/abilities.md
@@ -29,6 +29,12 @@ These requirements are documented in the `Required permission` column in the fol
 |:-----|:------------|:------------------|:---------|:--------------|:---------|
 | [`admin_compliance_framework`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/144183) |  | Create, read, update, and delete compliance frameworks. Users with this permission can also assign a compliance framework label to a project, and set the default framework of a group. | GitLab [17.0](https://gitlab.com/gitlab-org/gitlab/-/issues/411502) |  |  |
 
+## Continuous delivery
+
+| Name | Required permission | Description | Introduced in | Feature flag | Enabled in |
+|:-----|:------------|:------------------|:---------|:--------------|:---------|
+| [`manage_deploy_tokens`](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151677) |  | Manage deploy tokens at the group or project level. | GitLab [17.0](https://gitlab.com/gitlab-org/gitlab/-/issues/448843) |  |  |
+
 ## Groups and projects
 
 | Name | Required permission | Description | Introduced in | Feature flag | Enabled in |
diff --git a/ee/app/controllers/ee/groups/settings/repository_controller.rb b/ee/app/controllers/ee/groups/settings/repository_controller.rb
index bbf75f0797daf3bafbd672072ed25a2433760d6f..40c3bb5fb8d793e61a0690f6156734c8a96385ff 100644
--- a/ee/app/controllers/ee/groups/settings/repository_controller.rb
+++ b/ee/app/controllers/ee/groups/settings/repository_controller.rb
@@ -16,7 +16,8 @@ module RepositoryController
 
         override :authorize_access!
         def authorize_access!
-          render_404 unless can?(current_user, :admin_group, group) || can?(current_user, :change_push_rules, group)
+          render_404 unless can?(current_user, :admin_group, group) || can?(current_user, :change_push_rules, group) ||
+            can?(current_user, :manage_deploy_tokens, group)
         end
 
         def define_push_rule_variable
diff --git a/ee/app/controllers/ee/projects/settings/repository_controller.rb b/ee/app/controllers/ee/projects/settings/repository_controller.rb
index 0bb43f383ecac72dc35861ab26b4434d96bac4e0..35baae67dfb77a34e6d82e3d889fd44a96b4211e 100644
--- a/ee/app/controllers/ee/projects/settings/repository_controller.rb
+++ b/ee/app/controllers/ee/projects/settings/repository_controller.rb
@@ -8,6 +8,8 @@ module RepositoryController
         extend ::Gitlab::Utils::Override
 
         prepended do
+          skip_before_action :authorize_admin_project!, only: [:show, :create_deploy_token]
+          before_action :authorize_view_repository_settings!, only: [:show, :create_deploy_token]
           before_action :push_rule, only: :show
         end
 
@@ -89,6 +91,13 @@ def allow_protected_branches_for_group?(group)
           ::Feature.enabled?(:group_protected_branches, group) ||
             ::Feature.enabled?(:allow_protected_branches_for_group, group)
         end
+
+        def authorize_view_repository_settings!
+          return if can?(current_user, :admin_push_rules, project) ||
+            can?(current_user, :manage_deploy_tokens, project)
+
+          authorize_admin_project!
+        end
       end
     end
   end
diff --git a/ee/app/policies/ee/group_policy.rb b/ee/app/policies/ee/group_policy.rb
index 016219fd21554a570a54f5203e7668d6ad3eed76..097a3ad2791fa99559ee3f173e70839d0d52a4be 100644
--- a/ee/app/policies/ee/group_policy.rb
+++ b/ee/app/policies/ee/group_policy.rb
@@ -550,10 +550,17 @@ module GroupPolicy
         enable :admin_push_rules
       end
 
-      rule { can?(:admin_group) | can?(:admin_compliance_framework) }.policy do
+      rule { can?(:admin_group) | can?(:admin_compliance_framework) | can?(:manage_deploy_tokens) }.policy do
         enable :view_edit_page
       end
 
+      rule { custom_role_enables_manage_deploy_tokens }.policy do
+        enable :manage_deploy_tokens
+        enable :read_deploy_token
+        enable :create_deploy_token
+        enable :destroy_deploy_token
+      end
+
       rule { can?(:read_vulnerability) }.policy do
         enable :read_group_security_dashboard
         enable :create_vulnerability_export
diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb
index d8fbab0c1f291e4029f2c1cc38b0a071c7a4d2b5..995fb29198c5d8c683dff68f44525dc6d900dadf 100644
--- a/ee/app/policies/ee/project_policy.rb
+++ b/ee/app/policies/ee/project_policy.rb
@@ -529,6 +529,7 @@ module ProjectPolicy
         enable :modify_merge_request_committer_setting
         enable :modify_product_analytics_settings
         enable :admin_push_rules
+        enable :manage_deploy_tokens
       end
 
       rule { license_scanning_enabled & can?(:maintainer_access) }.enable :admin_software_license_policy
@@ -778,6 +779,13 @@ module ProjectPolicy
         enable :admin_compliance_framework
       end
 
+      rule { custom_role_enables_manage_deploy_tokens }.policy do
+        enable :manage_deploy_tokens
+        enable :read_deploy_token
+        enable :create_deploy_token
+        enable :destroy_deploy_token
+      end
+
       rule { can?(:create_issue) & okrs_enabled }.policy do
         enable :create_objective
         enable :create_key_result
diff --git a/ee/config/custom_abilities/manage_deploy_tokens.yml b/ee/config/custom_abilities/manage_deploy_tokens.yml
new file mode 100644
index 0000000000000000000000000000000000000000..1a16216d6cd49a64e3f0165238472d164bb23df5
--- /dev/null
+++ b/ee/config/custom_abilities/manage_deploy_tokens.yml
@@ -0,0 +1,11 @@
+---
+name: manage_deploy_tokens
+description: Manage deploy tokens at the group or project level.
+introduced_by_issue: https://gitlab.com/gitlab-org/gitlab/-/issues/448843
+introduced_by_mr: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/151677
+feature_category: continuous_delivery
+milestone: "17.0"
+group_ability: true
+project_ability: true
+requirements: []
+available_from_access_level:
diff --git a/ee/lib/ee/sidebars/groups/menus/settings_menu.rb b/ee/lib/ee/sidebars/groups/menus/settings_menu.rb
index e00a9734828b1a19e44b6c3086637bb84b9814ad..04ae18bd753232772b320144348841285a338789 100644
--- a/ee/lib/ee/sidebars/groups/menus/settings_menu.rb
+++ b/ee/lib/ee/sidebars/groups/menus/settings_menu.rb
@@ -26,7 +26,7 @@ def configure_menu_items
             else
               add_menu_item_for_abilities(general_menu_item, [:remove_group, :admin_compliance_framework])
               add_menu_item_for_abilities(access_tokens_menu_item, :read_resource_access_tokens)
-              add_menu_item_for_abilities(repository_menu_item, :admin_push_rules)
+              add_menu_item_for_abilities(repository_menu_item, [:admin_push_rules, :manage_deploy_tokens])
               add_menu_item_for_abilities(ci_cd_menu_item, :admin_cicd_variables)
               add_menu_item_for_abilities(billing_menu_item, :read_billing)
             end
diff --git a/ee/lib/ee/sidebars/projects/menus/settings_menu.rb b/ee/lib/ee/sidebars/projects/menus/settings_menu.rb
index a9d1015ec0cf80971b2caa9916a46e70fec2a6a1..215bb866a6f08b52f3decc8723bcb2a8db2dfd54 100644
--- a/ee/lib/ee/sidebars/projects/menus/settings_menu.rb
+++ b/ee/lib/ee/sidebars/projects/menus/settings_menu.rb
@@ -44,8 +44,8 @@ def custom_roles_menu_items
 
             items << general_menu_item if custom_roles_general_menu_item?
             items << access_tokens_menu_item if custom_roles_access_token_menu_item?
-            items << ci_cd_menu_item if custom_roles_ci_cd_menu_item?
             items << repository_menu_item if custom_roles_repository_menu_item?
+            items << ci_cd_menu_item if custom_roles_ci_cd_menu_item?
 
             items
           end
@@ -58,12 +58,13 @@ def custom_roles_access_token_menu_item?
             can?(context.current_user, :manage_resource_access_tokens, context.project)
           end
 
-          def custom_roles_ci_cd_menu_item?
-            can?(context.current_user, :admin_cicd_variables, context.project)
+          def custom_roles_repository_menu_item?
+            can?(context.current_user, :admin_push_rules, context.project) ||
+              can?(context.current_user, :manage_deploy_tokens, context.project)
           end
 
-          def custom_roles_repository_menu_item?
-            can?(context.current_user, :admin_push_rules, context.project)
+          def custom_roles_ci_cd_menu_item?
+            can?(context.current_user, :admin_cicd_variables, context.project)
           end
         end
       end
diff --git a/ee/spec/lib/ee/sidebars/groups/menus/settings_menu_spec.rb b/ee/spec/lib/ee/sidebars/groups/menus/settings_menu_spec.rb
index 72802db9112167ae9d63da6dfe36487fa9e76e93..38ebd24dd60cc5722f968f7852ce7689c2d753a0 100644
--- a/ee/spec/lib/ee/sidebars/groups/menus/settings_menu_spec.rb
+++ b/ee/spec/lib/ee/sidebars/groups/menus/settings_menu_spec.rb
@@ -403,5 +403,27 @@
         end
       end
     end
+
+    context 'when the user is not an owner but has `manage_deploy_tokens` custom permission', feature_category: :continuous_delivery do
+      let_it_be(:user) { create(:user) }
+
+      subject { menu.renderable_items.find { |e| e.item_id == item_id } }
+
+      before do
+        allow(Ability).to receive(:allowed?).and_call_original
+        allow(Ability).to receive(:allowed?).with(user, :admin_group, group).and_return(false)
+        allow(Ability).to receive(:allowed?).with(user, :manage_deploy_tokens, group).and_return(true)
+      end
+
+      describe 'General menu item' do
+        let(:item_id) { :repository }
+
+        it { is_expected.to be_present }
+
+        it 'does not show any other menu items' do
+          expect(menu.renderable_items.length).to equal(1)
+        end
+      end
+    end
   end
 end
diff --git a/ee/spec/lib/ee/sidebars/projects/menus/settings_menu_spec.rb b/ee/spec/lib/ee/sidebars/projects/menus/settings_menu_spec.rb
index b7dc170037d564533385e667c1bf417f3af31fd1..37f27647c8fe9ad261a3de765ca8a198b19a023e 100644
--- a/ee/spec/lib/ee/sidebars/projects/menus/settings_menu_spec.rb
+++ b/ee/spec/lib/ee/sidebars/projects/menus/settings_menu_spec.rb
@@ -130,6 +130,18 @@
           expect(subject.title).to eql('Repository')
         end
       end
+
+      describe 'when the user is not an admin but has the `manage_deploy_tokens` custom permission' do
+        before do
+          allow(Ability).to receive(:allowed?).and_call_original
+          allow(Ability).to receive(:allowed?).with(user, :admin_project, project).and_return(false)
+          allow(Ability).to receive(:allowed?).with(user, :manage_deploy_tokens, project).and_return(true)
+        end
+
+        it 'includes Repository menu item' do
+          expect(subject.title).to eql('Repository')
+        end
+      end
     end
   end
 end
diff --git a/ee/spec/policies/group_policy_spec.rb b/ee/spec/policies/group_policy_spec.rb
index 4d53868a75b8e0e71861f253c84ccb7aabad0a1c..3d8c4f28b2ac6427772a96ba17520b836ae77454 100644
--- a/ee/spec/policies/group_policy_spec.rb
+++ b/ee/spec/policies/group_policy_spec.rb
@@ -3563,6 +3563,16 @@ def create_member_role(member, abilities = member_role_abilities)
 
       it_behaves_like 'custom roles abilities'
     end
+
+    context 'for a custom role with the `manage_deploy_tokens` permission' do
+      let(:member_role_abilities) { { manage_deploy_tokens: true } }
+
+      let(:allowed_abilities) do
+        [:manage_deploy_tokens, :read_deploy_token, :create_deploy_token, :destroy_deploy_token, :view_edit_page]
+      end
+
+      it_behaves_like 'custom roles abilities'
+    end
   end
 
   context 'for :read_limit_alert' do
diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb
index d94c50fd08ac98c3fe72084d15707804966ecaa9..29f85f5f700d8d3a0dee5cd0e0faf4762f422175 100644
--- a/ee/spec/policies/project_policy_spec.rb
+++ b/ee/spec/policies/project_policy_spec.rb
@@ -2989,6 +2989,13 @@ def create_member_role(member, abilities = member_role_abilities)
 
       it_behaves_like 'custom roles abilities'
     end
+
+    context 'for a member role with `manage_deploy_tokens` true' do
+      let(:member_role_abilities) { { manage_deploy_tokens: true } }
+      let(:allowed_abilities) { [:manage_deploy_tokens, :read_deploy_token, :create_deploy_token, :destroy_deploy_token] }
+
+      it_behaves_like 'custom roles abilities'
+    end
   end
 
   describe 'permissions for suggested reviewers bot', :saas do
diff --git a/ee/spec/requests/custom_roles/manage_deploy_tokens/request_spec.rb b/ee/spec/requests/custom_roles/manage_deploy_tokens/request_spec.rb
new file mode 100644
index 0000000000000000000000000000000000000000..ef5acf2a362373e4ef6ccbf19d51dc8340215377
--- /dev/null
+++ b/ee/spec/requests/custom_roles/manage_deploy_tokens/request_spec.rb
@@ -0,0 +1,188 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe 'User with manage_deploy_tokens custom role', feature_category: :continuous_delivery do
+  include ApiHelpers
+
+  let_it_be(:user) { create(:user) }
+  let_it_be(:group) { create(:group) }
+  let_it_be(:project) { create(:project, :repository, namespace: group) }
+  let_it_be_with_reload(:role) { create(:member_role, :guest, namespace: group, manage_deploy_tokens: true) }
+
+  before do
+    stub_licensed_features(custom_roles: true)
+
+    sign_in(user)
+  end
+
+  describe 'manage project deploy tokens' do
+    let_it_be(:membership) { create(:project_member, :guest, user: user, source: project, member_role: role) }
+    let_it_be(:deploy_token) { create(:deploy_token, projects: [project]) }
+
+    describe Projects::Settings::RepositoryController do
+      describe '#show' do
+        it 'user has access via a custom role' do
+          get project_settings_repository_path(project)
+
+          expect(response).to have_gitlab_http_status(:ok)
+          expect(response.body).to have_text(s_('DeployTokens|Deploy tokens'))
+        end
+      end
+
+      describe '#create_deploy_token' do
+        it 'user has access via a custom role' do
+          params = { deploy_token: { name: 'name', expires_at: 1.day.from_now.to_datetime.to_s, read_repository: '1' } }
+
+          expect do
+            post create_deploy_token_project_settings_repository_path(project, params: params, format: :json)
+          end.to change { project.deploy_tokens.count }.by(1)
+
+          expect(response).to have_gitlab_http_status(:created)
+          expect(response).to match_response_schema('public_api/v4/deploy_token')
+        end
+      end
+    end
+
+    describe Projects::DeployTokensController do
+      describe '#revoke' do
+        it 'user has access via a custom role' do
+          expect do
+            put revoke_project_deploy_token_path(project, deploy_token)
+          end.to change { deploy_token.reload.revoked }.to(true)
+
+          expect(response).to have_gitlab_http_status(:redirect)
+          expect(response).to redirect_to(project_settings_repository_path(project, anchor: 'js-deploy-tokens'))
+        end
+      end
+    end
+
+    describe API::DeployTokens do
+      let_it_be(:url) { "/projects/#{project.id}/deploy_tokens" }
+
+      describe 'GET all tokens' do
+        it 'user has access via a custom role' do
+          get api(url, user)
+
+          expect(response).to have_gitlab_http_status(:ok)
+          expect(response).to match_response_schema('public_api/v4/deploy_tokens')
+        end
+      end
+
+      describe 'GET a single token' do
+        it 'user has access via a custom role' do
+          get api("#{url}/#{deploy_token.id}", user)
+
+          expect(response).to have_gitlab_http_status(:ok)
+          expect(response).to match_response_schema('public_api/v4/deploy_token')
+        end
+      end
+
+      describe 'POST' do
+        it 'user has access via a custom role' do
+          expect do
+            post api(url, user), params: { name: 'Foo', expires_at: 1.day.from_now, scopes: ['read_repository'] }
+          end.to change { DeployToken.count }.by(1)
+
+          expect(response).to have_gitlab_http_status(:created)
+          expect(response).to match_response_schema('public_api/v4/deploy_token')
+        end
+      end
+
+      describe 'DELETE' do
+        it 'user has access via a custom role' do
+          expect do
+            delete api("#{url}/#{deploy_token.id}", user)
+          end.to change { DeployToken.count }.by(-1)
+
+          expect(response).to have_gitlab_http_status(:no_content)
+        end
+      end
+    end
+  end
+
+  describe 'manage group deploy tokens' do
+    let_it_be(:membership) { create(:group_member, :guest, user: user, source: group, member_role: role) }
+    let_it_be(:deploy_token) { create(:deploy_token, :group, groups: [group]) }
+
+    describe Groups::Settings::RepositoryController do
+      describe '#show' do
+        it 'user has access via a custom role' do
+          get group_settings_repository_path(group)
+
+          expect(response).to have_gitlab_http_status(:ok)
+          expect(response.body).to have_text(s_('DeployTokens|Deploy tokens'))
+        end
+      end
+
+      describe '#create_deploy_token' do
+        it 'user has access via a custom role' do
+          params = { deploy_token: { name: 'name', expires_at: 1.day.from_now.to_datetime.to_s, read_repository: '1' } }
+
+          expect do
+            post create_deploy_token_group_settings_repository_path(group, params: params, format: :json)
+          end.to change { group.deploy_tokens.count }.by(1)
+
+          expect(response).to have_gitlab_http_status(:created)
+          expect(response).to match_response_schema('public_api/v4/deploy_token')
+        end
+      end
+    end
+
+    describe Groups::DeployTokensController do
+      describe '#revoke' do
+        it 'user has access via a custom role' do
+          expect do
+            put revoke_group_deploy_token_path(group, deploy_token)
+          end.to change { deploy_token.reload.revoked }.to(true)
+
+          expect(response).to have_gitlab_http_status(:redirect)
+          expect(response).to redirect_to(group_settings_repository_path(group, anchor: 'js-deploy-tokens'))
+        end
+      end
+    end
+
+    describe API::DeployTokens do
+      let_it_be(:url) { "/groups/#{group.id}/deploy_tokens" }
+
+      describe 'GET all tokens' do
+        it 'user has access via a custom role' do
+          get api(url, user)
+
+          expect(response).to have_gitlab_http_status(:ok)
+          expect(response).to match_response_schema('public_api/v4/deploy_tokens')
+        end
+      end
+
+      describe 'GET a single token' do
+        it 'user has access via a custom role' do
+          get api("#{url}/#{deploy_token.id}", user)
+
+          expect(response).to have_gitlab_http_status(:ok)
+          expect(response).to match_response_schema('public_api/v4/deploy_token')
+        end
+      end
+
+      describe 'POST' do
+        it 'user has access via a custom role' do
+          expect do
+            post api(url, user), params: { name: 'Foo', expires_at: 1.day.from_now, scopes: ['read_repository'] }
+          end.to change { DeployToken.count }.by(1)
+
+          expect(response).to have_gitlab_http_status(:created)
+          expect(response).to match_response_schema('public_api/v4/deploy_token')
+        end
+      end
+
+      describe 'DELETE' do
+        it 'user has access via a custom role' do
+          expect do
+            delete api("#{url}/#{deploy_token.id}", user)
+          end.to change { DeployToken.count }.by(-1)
+
+          expect(response).to have_gitlab_http_status(:no_content)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/helpers/deploy_tokens_helper_spec.rb b/spec/helpers/deploy_tokens_helper_spec.rb
index e5dd5ff79a2e13499d01403caee9f2cc3db01a13..3ec00c87141eda2403e11cc94d5a163bd62eb86a 100644
--- a/spec/helpers/deploy_tokens_helper_spec.rb
+++ b/spec/helpers/deploy_tokens_helper_spec.rb
@@ -2,7 +2,9 @@
 
 require 'spec_helper'
 
-RSpec.describe DeployTokensHelper do
+RSpec.describe DeployTokensHelper, feature_category: :continuous_delivery do
+  using RSpec::Parameterized::TableSyntax
+
   describe '#deploy_token_revoke_button_data' do
     let_it_be(:token) { build(:deploy_token) }
     let_it_be(:project) { build(:project) }
@@ -17,4 +19,62 @@
       })
     end
   end
+
+  describe '#container_registry_enabled?' do
+    let_it_be(:project) { build(:project) }
+    let_it_be(:user) { build(:user) }
+
+    where(:registry_enabled, :can_read_container_image, :can_manage_deploy_tokens, :result) do
+      true  | true  | true  | true
+      true  | true  | false | true
+      true  | false | true  | true
+      true  | false | false | false
+      false | true  | true  | false
+    end
+
+    with_them do
+      before do
+        allow(helper).to receive(:current_user).and_return(user)
+        allow(Gitlab.config.registry).to receive(:enabled).and_return(registry_enabled)
+        allow(Ability).to receive(:allowed?).and_call_original
+        allow(Ability).to receive(:allowed?).with(user, :read_container_image, project)
+          .and_return(can_read_container_image)
+        allow(Ability).to receive(:allowed?).with(user, :manage_deploy_tokens, project)
+          .and_return(can_manage_deploy_tokens)
+      end
+
+      it 'returns expected value' do
+        expect(helper.container_registry_enabled?(project)).to eq(result)
+      end
+    end
+  end
+
+  describe '#packages_registry_enabled?' do
+    let_it_be(:project) { build(:project) }
+    let_it_be(:user) { build(:user) }
+
+    where(:packages_enabled, :can_read_package, :can_manage_deploy_tokens, :result) do
+      true  | true  | true  | true
+      true  | true  | false | true
+      true  | false | true  | true
+      true  | false | false | false
+      false | true  | true  | false
+    end
+
+    with_them do
+      before do
+        allow(helper).to receive(:current_user).and_return(user)
+        allow(Gitlab.config.packages).to receive(:enabled).and_return(packages_enabled)
+        allow(Ability).to receive(:allowed?).and_call_original
+        allow(Ability).to receive(:allowed?).with(user, :read_package, instance_of(::Packages::Policies::Project))
+          .and_return(can_read_package)
+        allow(Ability).to receive(:allowed?).with(user, :manage_deploy_tokens, project)
+          .and_return(can_manage_deploy_tokens)
+      end
+
+      it 'returns expected value' do
+        expect(helper.packages_registry_enabled?(project)).to eq(result)
+      end
+    end
+  end
 end