diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 6f15bc553bfd4c302b2591689e610968e74f96fa..cee56dca538045fed252543714001b6f52c3e564 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -29,7 +29,8 @@ class ProjectsController < Projects::ApplicationController before_action :authorize_read_code!, only: [:refs] # Authorize - before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] + before_action :authorize_admin_project_or_custom_permissions!, only: :edit + before_action :authorize_admin_project!, only: [:update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] before_action :authorize_archive_project!, only: [:archive, :unarchive] before_action :event_filter, only: [:show, :activity] @@ -598,6 +599,11 @@ def check_export_rate_limit! def render_edit render 'edit' end + + # Overridden in EE + def authorize_admin_project_or_custom_permissions! + authorize_admin_project! + end end ProjectsController.prepend_mod_with('ProjectsController') diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 4e84a6ef7e75c69ffe1f0af47f98e8812b30e01a..fd0dc1178f7754fbff99de8337808303ee5d18b6 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -5,116 +5,119 @@ - reduce_visibility_form_id = 'reduce-visibility-form' - @force_desktop_expanded_sidebar = true -= render Pajamas::AlertComponent.new(title: _('GitLab Pages has moved'), +- if can?(current_user, :admin_project, @project) + = render Pajamas::AlertComponent.new(title: _('GitLab Pages has moved'), alert_options: { class: 'gl-my-5', data: { feature_id: Users::CalloutsHelper::PAGES_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c| - - c.with_body do - = _('To go to GitLab Pages, on the left sidebar, select %{pages_link}.').html_safe % {pages_link: link_to('Deploy > Pages', project_pages_path(@project)).html_safe} - -%section.settings.general-settings.no-animate.expanded#js-general-settings - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = _('Collapse') - %p.gl-text-secondary= _('Update your project name, topics, description, and avatar.') - .settings-content= render 'projects/settings/general' - -%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { testid: 'visibility-features-permissions-content' } } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded ? _('Collapse') : _('Expand') - %p.gl-text-secondary= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default emoji reactions.') - - .settings-content - = form_for @project, html: { multipart: true, class: "sharing-permissions-form", id: reduce_visibility_form_id }, authenticity_token: true do |f| - %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } - %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project).to_json.html_safe - .js-project-permissions-form{ data: visibility_confirm_modal_data(@project, reduce_visibility_form_id) } -- if show_merge_request_settings_callout?(@project) - %section.settings.expanded - = render Pajamas::AlertComponent.new(variant: :info, + - c.with_body do + = _('To go to GitLab Pages, on the left sidebar, select %{pages_link}.').html_safe % {pages_link: link_to('Deploy > Pages', project_pages_path(@project)).html_safe} + + %section.settings.general-settings.no-animate.expanded#js-general-settings + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = _('Collapse') + %p.gl-text-secondary= _('Update your project name, topics, description, and avatar.') + .settings-content= render 'projects/settings/general' + + %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { testid: 'visibility-features-permissions-content' } } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p.gl-text-secondary= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default emoji reactions.') + + .settings-content + = form_for @project, html: { multipart: true, class: "sharing-permissions-form", id: reduce_visibility_form_id }, authenticity_token: true do |f| + %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } + %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project).to_json.html_safe + .js-project-permissions-form{ data: visibility_confirm_modal_data(@project, reduce_visibility_form_id) } + - if show_merge_request_settings_callout?(@project) + %section.settings.expanded + = render Pajamas::AlertComponent.new(variant: :info, title: _('Merge requests and approvals settings have moved.'), alert_options: { class: 'js-merge-request-settings-callout gl-my-5', data: { feature_id: Users::CalloutsHelper::MERGE_REQUEST_SETTINGS_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c| - - c.with_body do - = _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe } - -%section.settings.no-animate{ class: ('expanded' if expanded), data: { testid: 'badges-settings-content' } } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = s_('ProjectSettings|Badges') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded ? _('Collapse') : _('Expand') - %p.gl-text-secondary - = s_('ProjectSettings|Customize this project\'s badges.') - = link_to s_('ProjectSettings|What are badges?'), help_page_path('user/project/badges') - .settings-content - = render 'shared/badges/badge_settings' - -= render_if_exists 'compliance_management/compliance_framework/project_settings', expanded: expanded - -= render_if_exists 'projects/settings/default_issue_template' - -= render 'projects/service_desk_settings' - -%section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { testid: 'advanced-settings-content' } } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded ? _('Collapse') : _('Expand') - %p.gl-text-secondary= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.') - - .settings-content - = render_if_exists 'projects/settings/restore', project: @project - - = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-mt-0' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| - - c.with_header do - .gl-new-card-title-wrapper - %h4.gl-new-card-title= _('Housekeeping') - %p.gl-new-card-description - = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') - = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer' - - - c.with_body do - .gl-display-flex.gl-flex-wrap.gl-gap-3 - = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do - = _('Run housekeeping') - #js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } } - - = render 'export', project: @project - - = render_if_exists 'projects/settings/archive' - - = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card rename-repository' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| - - c.with_header do - .gl-new-card-title-wrapper - %h4.gl-new-card-title.warning-title= _('Change path') - %p.gl-new-card-description - - link = link_to('', help_page_path('user/project/settings/index', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') - = safe_format(_("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) - - - c.with_body do - = render 'projects/errors' - = gitlab_ui_form_for @project do |f| - .form-group - %p - %span.gl-font-weight-bold= _("Be careful. Renaming a project's repository can have unintended side effects.") - = _('You will need to update your local repositories to point to the new location.') - - if @project.deployment_platform.present? - %p= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') - = f.label :path, _('Path'), class: 'label-bold' + - c.with_body do + = _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe } + + %section.settings.no-animate{ class: ('expanded' if expanded), data: { testid: 'badges-settings-content' } } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = s_('ProjectSettings|Badges') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p.gl-text-secondary + = s_('ProjectSettings|Customize this project\'s badges.') + = link_to s_('ProjectSettings|What are badges?'), help_page_path('user/project/badges') + .settings-content + = render 'shared/badges/badge_settings' + + = render_if_exists 'compliance_management/compliance_framework/project_settings', expanded: expanded + + = render_if_exists 'projects/settings/default_issue_template' + + = render 'projects/service_desk_settings' + + %section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { testid: 'advanced-settings-content' } } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p.gl-text-secondary= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.') + + .settings-content + = render_if_exists 'projects/settings/restore', project: @project + + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-mt-0' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title= _('Housekeeping') + %p.gl-new-card-description + = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') + = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer' + + - c.with_body do + .gl-display-flex.gl-flex-wrap.gl-gap-3 + = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do + = _('Run housekeeping') + #js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } } + + = render 'export', project: @project + + = render_if_exists 'projects/settings/archive' + + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card rename-repository' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title.warning-title= _('Change path') + %p.gl-new-card-description + - link = link_to('', help_page_path('user/project/settings/index', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) + + - c.with_body do + = render 'projects/errors' + = gitlab_ui_form_for @project do |f| .form-group - .input-group - .input-group-prepend - .input-group-text - #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ - = f.text_field :path, class: 'form-control gl-form-input-xl', data: { testid: 'project-path-field' } - = f.submit _('Change path'), class: "btn-danger", data: { testid: 'change-path-button' }, pajamas_button: true - - = render 'transfer', project: @project - - = render 'remove_fork', project: @project - - = render 'remove', project: @project + %p + %span.gl-font-weight-bold= _("Be careful. Renaming a project's repository can have unintended side effects.") + = _('You will need to update your local repositories to point to the new location.') + - if @project.deployment_platform.present? + %p= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') + = f.label :path, _('Path'), class: 'label-bold' + .form-group + .input-group + .input-group-prepend + .input-group-text + #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ + = f.text_field :path, class: 'form-control gl-form-input-xl', data: { testid: 'project-path-field' } + = f.submit _('Change path'), class: "btn-danger", data: { testid: 'change-path-button' }, pajamas_button: true + + = render 'transfer', project: @project + + = render 'remove_fork', project: @project + + = render 'remove', project: @project +- elsif can?(current_user, :archive_project, @project) + = render_if_exists 'projects/settings/archive' .save-project-loader.hide .center diff --git a/db/migrate/20231024123444_add_archive_project_to_member_roles.rb b/db/migrate/20231024123444_add_archive_project_to_member_roles.rb new file mode 100644 index 0000000000000000000000000000000000000000..27ff86450e83cf26034f3384c3e6e2007a538c0f --- /dev/null +++ b/db/migrate/20231024123444_add_archive_project_to_member_roles.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddArchiveProjectToMemberRoles < Gitlab::Database::Migration[2.1] + enable_lock_retries! + + def change + add_column :member_roles, :archive_project, :boolean, default: false, null: false + end +end diff --git a/db/schema_migrations/20231024123444 b/db/schema_migrations/20231024123444 new file mode 100644 index 0000000000000000000000000000000000000000..578f1cef1bdca9890283de1383b291ceee6ac27b --- /dev/null +++ b/db/schema_migrations/20231024123444 @@ -0,0 +1 @@ +db84d40c9afd9121aa24617167fa82b86cabc98bf274e61057eef02e1fafd7c3 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index ab1bf35fc90dce8063b66699d4578e824283a8fa..a75b1bbe8f8b82729e33ee3c11b9e383ddd7d3ed 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -18525,6 +18525,7 @@ CREATE TABLE member_roles ( admin_merge_request boolean DEFAULT false NOT NULL, admin_group_member boolean DEFAULT false NOT NULL, manage_project_access_tokens boolean DEFAULT false NOT NULL, + archive_project boolean DEFAULT false NOT NULL, CONSTRAINT check_4364846f58 CHECK ((char_length(description) <= 255)), CONSTRAINT check_9907916995 CHECK ((char_length(name) <= 255)) ); diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 1b83954a79432202035e8c68ad8f8baa5f30e681..9600020e8036b7c7d39544f05fdcb7f8ae880c28 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -5133,6 +5133,7 @@ Input type: `MemberRoleCreateInput` | `adminGroupMember` | [`Boolean`](#boolean) | Permission to admin group members. | | `adminMergeRequest` | [`Boolean`](#boolean) | Permission to admin merge requests. | | `adminVulnerability` | [`Boolean`](#boolean) | Permission to admin vulnerability. | +| `archiveProject` | [`Boolean`](#boolean) | Permission to archive projects. | | `baseAccessLevel` | [`MemberAccessLevel!`](#memberaccesslevel) | Base access level for the custom role. | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `description` | [`String`](#string) | Description of the member role. | @@ -20321,6 +20322,7 @@ Represents a member role. | `adminGroupMember` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to admin group members. | | `adminMergeRequest` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to admin merge requests. | | `adminVulnerability` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Permission to admin vulnerability. | +| `archiveProject` **{warning-solid}** | [`Boolean`](#boolean) | **Introduced** in 16.6. This feature is an Experiment. It can be changed or removed at any time. Permission to archive projects. | | `baseAccessLevel` **{warning-solid}** | [`AccessLevel!`](#accesslevel) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Base access level for the custom role. | | `description` | [`String`](#string) | Description of the member role. | | `enabledPermissions` **{warning-solid}** | [`[MemberRolePermission!]`](#memberrolepermission) | **Introduced** in 16.5. This feature is an Experiment. It can be changed or removed at any time. Array of all permissions enabled for the custom role. | @@ -29405,6 +29407,7 @@ Member role permission. | `ADMIN_GROUP_MEMBER` | Allows admin access to group members. | | `ADMIN_MERGE_REQUEST` | Allows admin access to the merge requests. | | `ADMIN_VULNERABILITY` | Allows admin access to the vulnerability reports. | +| `ARCHIVE_PROJECT` | Allows to archive projects. | | `MANAGE_PROJECT_ACCESS_TOKENS` | Allows manage access to the project access tokens. | | `READ_CODE` | Allows read-only access to the source code. | | `READ_DEPENDENCY` | Allows read-only access to the dependencies. | diff --git a/doc/api/member_roles.md b/doc/api/member_roles.md index cc50a8e225a339ab306583d4bc443cf6ba91d332..63de583de25614073d8db2fb7fa2d4073f7610af 100644 --- a/doc/api/member_roles.md +++ b/doc/api/member_roles.md @@ -13,12 +13,14 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - [Read dependency added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/126247) in GitLab 16.3. > - [Name and description fields added](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/126423) in GitLab 16.3. > - [Admin merge request introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/128302) in GitLab 16.4 [with a flag](../administration/feature_flags.md) named `admin_merge_request`. Disabled by default. +> - [Feature flag `admin_merge_request` removed](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132578) in GitLab 16.5. > - [Admin group members introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131914) in GitLab 16.5 [with a flag](../administration/feature_flags.md) named `admin_group_member`. Disabled by default. The feature flag has been removed in GitLab 16.6. > - [Manage project access tokens introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132342) in GitLab 16.5 in [with a flag](../administration/feature_flags.md) named `manage_project_access_tokens`. Disabled by default. +> - [Archive project introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134998) in GitLab 16.6 in [with a flag](../administration/feature_flags.md) named `archive_project`. Disabled by default. FLAG: -On self-managed GitLab, by default these two features are not available. To make them available, an administrator can [enable the feature flags](../administration/feature_flags.md) named `admin_merge_request` and `admin_member_custom_role`. -On GitLab.com, this feature is not available. +On self-managed GitLab, by default these features are not available. To make them available, an administrator can [enable the feature flags](../administration/feature_flags.md) named `admin_group_member`, `manage_project_access_tokens` and `archive_project`. +On GitLab.com, these features are not available. ## List all member roles of a group @@ -48,6 +50,7 @@ If successful, returns [`200`](rest/index.md#status-codes) and the following res | `[].read_vulnerability` | boolean | Permission to read project vulnerabilities. | | `[].admin_group_member` | boolean | Permission to admin members of a group. | | `[].manage_project_access_tokens` | boolean | Permission to manage project access tokens. | +| `[].archive_project` | boolean | Permission to archive projects. | Example request: @@ -70,7 +73,8 @@ Example response: "read_code": true, "read_dependency": false, "read_vulnerability": false, - "manage_project_access_tokens": false + "manage_project_access_tokens": false, + "archive_project": false }, { "id": 3, @@ -83,7 +87,8 @@ Example response: "read_code": false, "read_dependency": true, "read_vulnerability": true, - "manage_project_access_tokens": false + "manage_project_access_tokens": false, + "archive_project": false } ] ``` diff --git a/doc/user/custom_roles.md b/doc/user/custom_roles.md index 1b827d8279296a80b6e4d5f799938a88cc041c60..bbb487240782efbb1a4d09ccf9a47ff741b89ea8 100644 --- a/doc/user/custom_roles.md +++ b/doc/user/custom_roles.md @@ -15,6 +15,7 @@ info: To determine the technical writer assigned to the Stage/Group associated w > - Ability to create and remove a custom role with the UI [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/393235) in GitLab 16.4. > - Ability to manage group members [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/17364) in GitLab 16.5. > - Ability to manage project access tokens [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/421778) in GitLab 16.5 [with a flag](../administration/feature_flags.md) named `manage_project_access_tokens`. +> - Ability to archive projects [introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/425957) in GitLab 16.6 in [with a flag](../administration/feature_flags.md) named `archive_project`. Disabled by default. Custom roles allow group Owners or instance administrators to create roles specific to the needs of their organization. @@ -97,6 +98,7 @@ These requirements are documented in the `Required permission` column in the fol | `admin_merge_request` | GitLab 16.4 and later | Not applicable | View and approve [merge requests](project/merge_requests/index.md), and view the associated merge request code.
Does not allow users to view or change merge request approval rules. | | `manage_project_access_tokens` | GitLab 16.5 and later | Not applicable | Create, delete, and list [project access tokens](project/settings/project_access_tokens.md). | | `admin_group_member` | GitLab 16.5 and later | Not applicable | Add or remove [group members](group/manage.md). | +| `archive_project` | GitLab 16.6 and later | Not applicable | Archive and unarchive [projects](project/settings/index.md#archive-a-project). | ## Billing and seat usage diff --git a/ee/app/assets/javascripts/roles_and_permissions/components/create_member_role.vue b/ee/app/assets/javascripts/roles_and_permissions/components/create_member_role.vue index 0a37c86bf6886acdaf4c7eaaad178233e4c49acd..3ca33a4759c943546f0e3dd40a7050836c21c83c 100644 --- a/ee/app/assets/javascripts/roles_and_permissions/components/create_member_role.vue +++ b/ee/app/assets/javascripts/roles_and_permissions/components/create_member_role.vue @@ -62,9 +62,16 @@ export default { }, computed: { selectablePermissions() { - return gon.features.manageProjectAccessTokens - ? this.availablePermissions - : this.availablePermissions.filter(({ value }) => value !== 'manage_project_access_tokens'); + return this.availablePermissions.filter(({ value }) => { + switch (value) { + case 'manage_project_access_tokens': + return Boolean(gon.features.manageProjectAccessTokens); + case 'archive_project': + return Boolean(gon.features.archiveProject); + default: + return true; + } + }); }, }, methods: { diff --git a/ee/app/assets/javascripts/roles_and_permissions/components/list_member_roles.vue b/ee/app/assets/javascripts/roles_and_permissions/components/list_member_roles.vue index 08420e117443f3075960082431f46314ac9c5bc7..80a483255198ae8955344ed0155c42ba75525939 100644 --- a/ee/app/assets/javascripts/roles_and_permissions/components/list_member_roles.vue +++ b/ee/app/assets/javascripts/roles_and_permissions/components/list_member_roles.vue @@ -218,7 +218,7 @@ export default { diff --git a/ee/app/controllers/ee/projects_controller.rb b/ee/app/controllers/ee/projects_controller.rb index b9c2c63c5d5aaa8804a2547ee8d649d4aa87e0a6..a66458da13ac310944c618922938c6ba35b54ce1 100644 --- a/ee/app/controllers/ee/projects_controller.rb +++ b/ee/app/controllers/ee/projects_controller.rb @@ -222,5 +222,9 @@ def log_archive_audit_event def log_unarchive_audit_event log_audit_event(message: 'Project unarchived', event_type: 'project_unarchived') end + + def authorize_admin_project_or_custom_permissions! + can?(current_user, :archive_project, project) || super + end end end diff --git a/ee/app/controllers/groups/settings/roles_and_permissions_controller.rb b/ee/app/controllers/groups/settings/roles_and_permissions_controller.rb index 6ed3c81fdeba7462e5ebf193a0913a49f8ddc546..ec5d95865f16f46a0c6f53ef9f0514b7ac50f360 100644 --- a/ee/app/controllers/groups/settings/roles_and_permissions_controller.rb +++ b/ee/app/controllers/groups/settings/roles_and_permissions_controller.rb @@ -10,6 +10,7 @@ class RolesAndPermissionsController < Groups::ApplicationController before_action :ensure_custom_roles_available! before_action do push_frontend_feature_flag(:manage_project_access_tokens, group) + push_frontend_feature_flag(:archive_project, group) end private diff --git a/ee/app/graphql/mutations/member_roles/create.rb b/ee/app/graphql/mutations/member_roles/create.rb index c6c8a62218acad542f272fae569616a0c48a9ed3..297d2f868bb174f4b999a81512e71f1102d0ab30 100644 --- a/ee/app/graphql/mutations/member_roles/create.rb +++ b/ee/app/graphql/mutations/member_roles/create.rb @@ -21,6 +21,10 @@ class Create < Base GraphQL::Types::Boolean, required: false, description: 'Permission to admin vulnerability.' + argument :archive_project, + GraphQL::Types::Boolean, + required: false, + description: 'Permission to archive projects.' argument :base_access_level, ::Types::MemberAccessLevelEnum, required: true, diff --git a/ee/app/graphql/types/member_roles/member_role_type.rb b/ee/app/graphql/types/member_roles/member_role_type.rb index 7fed4e8b651685ea1f6947952dd187f2e8f565ee..de5fecfdfb09568a65b9602bceda5d91e30b2a0c 100644 --- a/ee/app/graphql/types/member_roles/member_role_type.rb +++ b/ee/app/graphql/types/member_roles/member_role_type.rb @@ -41,6 +41,12 @@ class MemberRoleType < BaseObject alpha: { milestone: '16.5' }, description: 'Permission to admin group members.' + field :archive_project, + GraphQL::Types::Boolean, + null: true, + alpha: { milestone: '16.6' }, + description: 'Permission to archive projects.' + field :manage_project_access_tokens, GraphQL::Types::Boolean, null: true, diff --git a/ee/app/models/members/member_role.rb b/ee/app/models/members/member_role.rb index 2c4819f31dff9e35fb153d73ff60b1e1926d1b71..d5ee2ddddd479ba516e17e25a3cbdeef60a005f5 100644 --- a/ee/app/models/members/member_role.rb +++ b/ee/app/models/members/member_role.rb @@ -24,6 +24,9 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass }, manage_project_access_tokens: { description: 'Allows manage access to the project access tokens' + }, + archive_project: { + description: 'Allows to archive projects' } }.freeze ALL_CUSTOMIZABLE_PROJECT_PERMISSIONS = [ @@ -32,7 +35,8 @@ class MemberRole < ApplicationRecord # rubocop:disable Gitlab/NamespacedClass :read_vulnerability, :admin_merge_request, :admin_vulnerability, - :manage_project_access_tokens + :manage_project_access_tokens, + :archive_project ].freeze ALL_CUSTOMIZABLE_GROUP_PERMISSIONS = [ :read_dependency, diff --git a/ee/app/policies/ee/project_policy.rb b/ee/app/policies/ee/project_policy.rb index c84f722fcd0bbdff35ada7c2510d5a8651f16693..63c952b7a6df9a6b0e22ff39bad9657e83171ab2 100644 --- a/ee/app/policies/ee/project_policy.rb +++ b/ee/app/policies/ee/project_policy.rb @@ -637,6 +637,23 @@ module ProjectPolicy ).has_ability? end + condition(:archive_project_enabled) do + ::Feature.enabled?(:archive_project, @subject.root_ancestor) + end + + desc "Custom role on project that enables archiving projects" + condition(:custom_role_enables_archive_projects) do + ::Auth::MemberRoleAbilityLoader.new( + user: @user, + resource: @subject, + ability: :archive_project + ).has_ability? + end + + rule { custom_roles_allowed & archive_project_enabled & custom_role_enables_archive_projects }.policy do + enable :archive_project + end + rule { needs_new_sso_session }.policy do prevent :read_project end diff --git a/ee/config/feature_flags/development/archive_project.yml b/ee/config/feature_flags/development/archive_project.yml new file mode 100644 index 0000000000000000000000000000000000000000..74b4c6b4a86faf53d93f300d744d9e798c8e16d4 --- /dev/null +++ b/ee/config/feature_flags/development/archive_project.yml @@ -0,0 +1,8 @@ +--- +name: archive_project +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134998 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/429239 +milestone: "16.6" +type: development +group: group::authorization +default_enabled: false diff --git a/ee/lib/ee/sidebars/projects/menus/settings_menu.rb b/ee/lib/ee/sidebars/projects/menus/settings_menu.rb index 5b931a1e0960d12489753c8d1abaf7f95db6d66c..56cc28e7d1faaf72228c85a0f1959761774b9a7c 100644 --- a/ee/lib/ee/sidebars/projects/menus/settings_menu.rb +++ b/ee/lib/ee/sidebars/projects/menus/settings_menu.rb @@ -28,6 +28,33 @@ def analytics_menu_item item_id: :analytics ) end + + private + + override :enabled_menu_items + def enabled_menu_items + return super if can?(context.current_user, :admin_project, context.project) + + custom_roles_menu_items + end + + def custom_roles_menu_items + items = [] + return items unless context.current_user + + items << general_menu_item if custom_roles_general_menu_item? + items << access_tokens_menu_item if custom_roles_access_token_menu_item? + + items + end + + def custom_roles_general_menu_item? + can?(context.current_user, :archive_project, context.project) + end + + def custom_roles_access_token_menu_item? + can?(context.current_user, :manage_resource_access_tokens, context.project) + end end end end diff --git a/ee/spec/controllers/projects_controller_spec.rb b/ee/spec/controllers/projects_controller_spec.rb index 613d1a02c4d0caf18b9a87b7324fdbcaced1816c..21ebd428f55661e8b54dfd5c6ac7228464439b68 100644 --- a/ee/spec/controllers/projects_controller_spec.rb +++ b/ee/spec/controllers/projects_controller_spec.rb @@ -170,16 +170,33 @@ end describe 'GET edit', feature_category: :groups_and_projects do + subject(:request) { get :edit, params: { namespace_id: project.namespace.path, id: project.path } } + it 'does not allow an auditor user to access the page' do sign_in(create(:user, :auditor)) - get :edit, params: { - namespace_id: project.namespace.path, - id: project.path - } + request expect(response).to have_gitlab_http_status(:not_found) end + + context 'when the user can archive projects' do + let_it_be(:guest) { create(:user) } + + before do + project.add_guest(guest) + allow(controller).to receive(:can?).and_call_original + allow(controller).to receive(:can?).with(guest, :archive_project, anything).and_return(true) + + sign_in(guest) + request + end + + it 'allows the user to access the page' do + expect(response).to have_gitlab_http_status(:ok) + expect(response).to render_template(:edit) + end + end end describe 'POST create', feature_category: :groups_and_projects do diff --git a/ee/spec/features/projects/custom_roles/archive_project_custom_permission_spec.rb b/ee/spec/features/projects/custom_roles/archive_project_custom_permission_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..5050be486042025df20c26c2395d2d13bace18d4 --- /dev/null +++ b/ee/spec/features/projects/custom_roles/archive_project_custom_permission_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Archive Project Custom Permission', feature_category: :groups_and_projects do + let_it_be(:project) { create(:project, :in_group) } + let_it_be(:user) { create(:user) } + let_it_be(:custom_role) { create(:member_role, :guest, namespace: project.root_namespace, archive_project: true) } + + before do + stub_licensed_features(custom_roles: true) + create(:project_member, :guest, member_role: custom_role, user: user, project: project) + sign_in(user) + end + + context 'when the project is not archived', :js, :aggregate_failures do + it 'allows a guest user with custom `archive_project` permissions to archive it' do + visit project_path(project) + + within_testid('super-sidebar') do + click_button('Settings') + click_link('General') + end + + click_link('Archive project') + click_button('Archive project') + + expect(page).to have_current_path(project_path(project)) + expect(project.reload.archived?).to eq(true) + end + end + + context 'when the project is archived', :js, :aggregate_failures do + let_it_be(:project) { create(:project, :archived, namespace: project.namespace) } + + it 'allows a guest user with custom `archive_project` permissions to unarchive it' do + visit project_path(project) + + within_testid('super-sidebar') do + click_button('Settings') + click_link('General') + end + + click_link('Unarchive project') + click_button('Unarchive project') + + expect(page).to have_current_path(project_path(project)) + expect(project.reload).not_to be_archived + end + end + + shared_examples 'does not allow the user to archive the project' do + it 'does not show the `Settings` sidebar item', :js do + visit project_path(project) + + within_testid('super-sidebar') do + expect(page).not_to have_button('Settings') + end + end + + it 'does not allow access to the edit page' do + visit edit_project_path(project) + + expect(page).to have_gitlab_http_status(:not_found) + end + end + + context 'when the `archive_project` feature flag is disabled' do + before do + stub_feature_flags(archive_project: false) + end + + it_behaves_like 'does not allow the user to archive the project' + end + + context 'when the `custom_roles` licensed feature is not available' do + before do + stub_licensed_features(custom_roles: false) + end + + it_behaves_like 'does not allow the user to archive the project' + end + + context 'when the user does not have the custom `archive_project` permission' do + let_it_be(:custom_role) { create(:member_role, :guest, namespace: project.root_namespace, archive_project: false) } + + it_behaves_like 'does not allow the user to archive the project' + end +end diff --git a/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js b/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js index 2e87d5321c11896d62214bd06574da7cd335c6a1..76600c804636266f4e88841dd5e7b548b91e1153 100644 --- a/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js +++ b/ee/spec/frontend/roles_and_permissions/components/create_member_role_spec.js @@ -95,6 +95,21 @@ describe('CreateMemberRole', () => { }); }); + describe('archive_project feature flag is on', () => { + beforeEach(() => { + window.gon.features = { archiveProject: true }; + }); + + it('renders archive project permission', () => { + const permission = { name: 'Permission E', description: 'Description E' }; + createComponent({ availablePermissions: [permission] }); + const checkbox = findCheckboxes().at(0); + + expect(checkbox.text()).toContain(permission.name); + expect(checkbox.text()).toContain(permission.description); + }); + }); + it('emits cancel event', () => { expect(wrapper.emitted('cancel')).toBeUndefined(); diff --git a/ee/spec/frontend/roles_and_permissions/components/list_member_roles_spec.js b/ee/spec/frontend/roles_and_permissions/components/list_member_roles_spec.js index 634574006e76f1c70126ec8e519bad6e7a5fed0d..ec49910debd3052ee15a63a6eb28f7f1651bf04c 100644 --- a/ee/spec/frontend/roles_and_permissions/components/list_member_roles_spec.js +++ b/ee/spec/frontend/roles_and_permissions/components/list_member_roles_spec.js @@ -96,16 +96,28 @@ describe('ListMemberRoles', () => { getMemberRoles.mockResolvedValue({ data: mockResponse }); }); - it('shows empty state', () => { - createComponent({ groupId: null }); - expect(wrapper.findByTestId('card-title').text()).toMatch(ListMemberRoles.i18n.cardTitle); - expect(findCounter().text()).toBe('0'); - expect(findAddRoleButton().props('disabled')).toBe(true); - expect(findEmptyState().props()).toMatchObject({ - description: emptyText, - title: ListMemberRoles.i18n.emptyTitle, + describe('empty state', () => { + beforeEach(() => { + getMemberRoles.mockResolvedValue({ data: [] }); + createComponent({ groupId }); + }); + + it('shows empty state', () => { + expect(wrapper.findByTestId('card-title').text()).toMatch(ListMemberRoles.i18n.cardTitle); + expect(findCounter().text()).toBe('0'); + expect(findAddRoleButton().props('disabled')).toBe(false); + expect(findEmptyState().props()).toMatchObject({ + description: emptyText, + title: ListMemberRoles.i18n.emptyTitle, + }); + expect(findCreateMemberRole().exists()).toBe(false); + }); + + it('hides empty state when toggling the form', async () => { + findAddRoleButton().vm.$emit('click'); + await waitForPromises(); + expect(findEmptyState().exists()).toBe(false); }); - expect(findCreateMemberRole().exists()).toBe(false); }); describe('fetching roles', () => { diff --git a/ee/spec/lib/ee/api/entities/member_role_spec.rb b/ee/spec/lib/ee/api/entities/member_role_spec.rb index 9fe99b298de87747977ad46fe4d02ab7c2a15b33..83ee63da11f01a7a85a1ae475e6628a0adfb189f 100644 --- a/ee/spec/lib/ee/api/entities/member_role_spec.rb +++ b/ee/spec/lib/ee/api/entities/member_role_spec.rb @@ -20,7 +20,8 @@ expect(subject[:read_code]).to eq member_role.read_code expect(subject[:read_vulnerability]).to eq member_role.read_vulnerability expect(subject[:admin_vulnerability]).to eq member_role.admin_vulnerability - expect(subject[:manage_project_access_tokens]).to eq member_role.admin_vulnerability + expect(subject[:manage_project_access_tokens]).to eq member_role.manage_project_access_tokens + expect(subject[:archive_project]).to eq member_role.archive_project expect(subject[:group_id]).to eq(member_role.namespace.id) 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 4f0b754f230bda2ce1d5b542ebf505591b7c3c73..e439bc7ea8290fbeef43775c1efc83094617949d 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 @@ -37,5 +37,37 @@ expect(subject).to be_nil end end + + describe 'General' do + let(:item_id) { :general } + + describe 'when the user is not an admin but has archive_project 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, :archive_project, project).and_return(true) + end + + it 'includes general menu item' do + expect(subject.title).to eql('General') + end + end + end + + describe 'Access Tokens' do + let(:item_id) { :access_tokens } + + describe 'when the user is not an admin but has manage_resource_access_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_resource_access_tokens, project).and_return(true) + end + + it 'includes access token menu item' do + expect(subject.title).to eql('Access Tokens') + end + end + end end end diff --git a/ee/spec/models/ee/user_spec.rb b/ee/spec/models/ee/user_spec.rb index 74f53c3ec850c272a953e12e8e3099db4e320f08..1d20cb0723539ab8d3ca9b205683c7be5f1c47a6 100644 --- a/ee/spec/models/ee/user_spec.rb +++ b/ee/spec/models/ee/user_spec.rb @@ -1182,7 +1182,8 @@ OR admin_vulnerability = true OR read_dependency = true OR read_vulnerability = true - OR manage_project_access_tokens = true\)\)\)\)'.squish # allow_cross_joins_across_databases + OR manage_project_access_tokens = true + OR archive_project = true\)\)\)\)'.squish # allow_cross_joins_across_databases end before do diff --git a/ee/spec/policies/project_policy_spec.rb b/ee/spec/policies/project_policy_spec.rb index b2ce51ac62dc2062f1fbf9d7bf0570c28a73e6c8..e360bf39cb80892633c42a30d9ce287050e632e4 100644 --- a/ee/spec/policies/project_policy_spec.rb +++ b/ee/spec/policies/project_policy_spec.rb @@ -2761,6 +2761,29 @@ def create_member_role(member, abilities = member_role_abilities) it { is_expected.to be_disallowed(*disallowed_abilities) } end end + + context 'for a member role with archive_project true' do + let(:member_role_abilities) { { archive_project: true } } + let(:allowed_abilities) { [:archive_project] } + + context 'with archive_project FF enabled' do + before do + stub_feature_flags(archive_project: [project.group]) + end + + it_behaves_like 'custom roles abilities' + end + + context 'with archive_project FF disabled' do + before do + stub_feature_flags(archive_project: false) + end + + let(:disallowed_abilities) { [:archive_project] } + + it { is_expected.to be_disallowed(*disallowed_abilities) } + end + end end describe 'permissions for suggested reviewers bot', :saas do diff --git a/ee/spec/requests/api/member_roles_spec.rb b/ee/spec/requests/api/member_roles_spec.rb index 06ef35355f0b363a02a1924e4b8a6d036eba1a5c..fff5e48f90bfac0e1d8523a24f8124c4fc016751 100644 --- a/ee/spec/requests/api/member_roles_spec.rb +++ b/ee/spec/requests/api/member_roles_spec.rb @@ -106,6 +106,7 @@ "admin_merge_request" => false, "admin_vulnerability" => false, "manage_project_access_tokens" => false, + "archive_project" => false, "group_id" => group_id }, { @@ -120,6 +121,7 @@ "admin_merge_request" => true, "admin_vulnerability" => false, "manage_project_access_tokens" => false, + "archive_project" => false, "group_id" => group_id } ] diff --git a/ee/spec/views/projects/edit.html.haml_spec.rb b/ee/spec/views/projects/edit.html.haml_spec.rb index 5c7ec8341d3ac02e4281b38e630d876ef7a8d345..1ff41577815c54005cc41c4487742119473b1a00 100644 --- a/ee/spec/views/projects/edit.html.haml_spec.rb +++ b/ee/spec/views/projects/edit.html.haml_spec.rb @@ -38,4 +38,27 @@ it_behaves_like 'does not render registration features prompt', :project_disabled_repository_size_limit end end + + context 'when rendering for a user that is not an owner' do + let_it_be(:user) { create(:user) } + + before do + allow(view).to receive(:can?).with(user, :archive_project, project).and_return(can_archive_projects) + render + end + + subject { rendered } + + context 'when the user can archive projects' do + let(:can_archive_projects) { true } + + it { is_expected.to have_link(_('Archive project')) } + end + + context 'when the user cannot archive projects' do + let(:can_archive_projects) { false } + + it { is_expected.not_to have_link(_('Archive project')) } + end + end end diff --git a/lib/sidebars/projects/menus/settings_menu.rb b/lib/sidebars/projects/menus/settings_menu.rb index 8fed1c46425eaccbc36a5eff869c06639a023067..077eebf58b9e1d4d265eeed25963c12355df86dd 100644 --- a/lib/sidebars/projects/menus/settings_menu.rb +++ b/lib/sidebars/projects/menus/settings_menu.rb @@ -57,10 +57,6 @@ def enabled_menu_items monitor_menu_item, usage_quotas_menu_item ] - elsif context.current_user && can?(context.current_user, :manage_resource_access_tokens, context.project) - [ - access_tokens_menu_item - ] else [] end diff --git a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb index 81ca9670ac6f152a0803f0a45671b9d47c5ba00c..605cec8be5e3003aa36f75fbaa10b031e6c8eb28 100644 --- a/spec/lib/sidebars/projects/menus/settings_menu_spec.rb +++ b/spec/lib/sidebars/projects/menus/settings_menu_spec.rb @@ -59,18 +59,6 @@ let(:item_id) { :access_tokens } it_behaves_like 'access rights checks' - - describe 'when the user is not an admin but has manage_resource_access_tokens' 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_resource_access_tokens, project).and_return(true) - end - - it 'includes access token menu item' do - expect(subject.title).to eql('Access Tokens') - end - end end describe 'Repository' do