Commit 1a78d2ef authored by Mikołaj Wawrzyniak's avatar Mikołaj Wawrzyniak

Merge branch 'dep-approval-multi-access-levels-api-interface' into 'master'

Implement API interface for Deployment Approval Rules

See merge request gitlab-org/gitlab!84692
parents 2d446d4d 51c52f24
...@@ -265,7 +265,7 @@ Example response: ...@@ -265,7 +265,7 @@ Example response:
} }
``` ```
Deployments created by users on GitLab Premium or higher include the `approvals` and `pending_approval_count` properties: When the [unified approval setting](../ci/environments/deployment_approvals.md#unified-approval-setting) is configured, deployments created by users on GitLab Premium or higher include the `approvals` and `pending_approval_count` properties:
```json ```json
{ {
...@@ -290,6 +290,48 @@ Deployments created by users on GitLab Premium or higher include the `approvals` ...@@ -290,6 +290,48 @@ Deployments created by users on GitLab Premium or higher include the `approvals`
} }
``` ```
When the [multiple approval rules](../ci/environments/deployment_approvals.md#multiple-approval-rules) is configured, deployments created by users on GitLab Premium or higher include the `approval_summary` property:
```json
{
"approval_summary": {
"rules": [
{
"user_id": null,
"group_id": 134,
"access_level": null,
"access_level_description": "qa-group",
"required_approvals": 1,
"deployment_approvals": []
},
{
"user_id": null,
"group_id": 135,
"access_level": null,
"access_level_description": "security-group",
"required_approvals": 2,
"deployment_approvals": [
{
"user": {
"id": 100,
"username": "security-user-1",
"name": "security user-1",
"state": "active",
"avatar_url": "https://www.gravatar.com/avatar/e130fcd3a1681f41a3de69d10841afa9?s=80&d=identicon",
"web_url": "http://localhost:3000/security-user-1"
},
"status": "approved",
"created_at": "2022-04-11T03:37:03.058Z",
"comment": null
}
]
}
]
}
...
}
```
## Create a deployment ## Create a deployment
```plaintext ```plaintext
...@@ -455,9 +497,10 @@ POST /projects/:id/deployments/:deployment_id/approval ...@@ -455,9 +497,10 @@ POST /projects/:id/deployments/:deployment_id/approval
| `deployment_id` | integer | yes | The ID of the deployment. | | `deployment_id` | integer | yes | The ID of the deployment. |
| `status` | string | yes | The status of the approval (either `approved` or `rejected`). | | `status` | string | yes | The status of the approval (either `approved` or `rejected`). |
| `comment` | string | no | A comment to go with the approval | | `comment` | string | no | A comment to go with the approval |
| `represented_as`| string | no | The name of the User/Group/Role to use for the approval, when the user belongs to [multiple approval rules](../ci/environments/deployment_approvals.md#multiple-approval-rules). |
```shell ```shell
curl --data "status=approved&comment=Looks good to me" \ curl --data "status=approved&comment=Looks good to me&represented_as=security" \
--header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/1/approval" --header "PRIVATE-TOKEN: <your_access_token>" "https://gitlab.example.com/api/v4/projects/1/deployments/1/approval"
``` ```
...@@ -466,12 +509,12 @@ Example response: ...@@ -466,12 +509,12 @@ Example response:
```json ```json
{ {
"user": { "user": {
"name": "Administrator", "id": 100,
"username": "root", "username": "security-user-1",
"id": 1, "name": "security user-1",
"state": "active", "state": "active",
"avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", "avatar_url": "https://www.gravatar.com/avatar/e130fcd3a1681f41a3de69d10841afa9?s=80&d=identicon",
"web_url": "http://localhost:3000/root" "web_url": "http://localhost:3000/security-user-1"
}, },
"status": "approved", "status": "approved",
"created_at": "2022-02-24T20:22:30.097Z", "created_at": "2022-02-24T20:22:30.097Z",
......
...@@ -107,6 +107,7 @@ POST /groups/:id/protected_environments ...@@ -107,6 +107,7 @@ POST /groups/:id/protected_environments
| `name` | string | yes | The deployment tier of the protected environment. One of `production`, `staging`, `testing`, `development`, or `other`. Read more about [deployment tiers](../ci/environments/index.md#deployment-tier-of-environments).| | `name` | string | yes | The deployment tier of the protected environment. One of `production`, `staging`, `testing`, `development`, or `other`. Read more about [deployment tiers](../ci/environments/index.md#deployment-tier-of-environments).|
| `deploy_access_levels` | array | yes | Array of access levels allowed to deploy, with each described by a hash. One of `user_id`, `group_id` or `access_level`. They take the form of `{user_id: integer}`, `{group_id: integer}` or `{access_level: integer}` respectively. | | `deploy_access_levels` | array | yes | Array of access levels allowed to deploy, with each described by a hash. One of `user_id`, `group_id` or `access_level`. They take the form of `{user_id: integer}`, `{group_id: integer}` or `{access_level: integer}` respectively. |
| `required_approval_count` | integer | no | The number of approvals required to deploy to this environment. This is part of Deployment Approvals, which isn't yet available for use. For details, see [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/343864). | | `required_approval_count` | integer | no | The number of approvals required to deploy to this environment. This is part of Deployment Approvals, which isn't yet available for use. For details, see [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/343864). |
| `approval_rules` | array | no | Array of access levels allowed to approve, with each described by a hash. One of `user_id`, `group_id` or `access_level`. They take the form of `{user_id: integer}`, `{group_id: integer}` or `{access_level: integer}` respectively. You can also specify the number of required approvals from the specified entity with `required_approvals` field. See [Multiple approval rules](../ci/environments/deployment_approvals.md#multiple-approval-rules) for more information. |
The assignable `user_id` are the users who belong to the given group with the Maintainer role (or above). The assignable `user_id` are the users who belong to the given group with the Maintainer role (or above).
The assignable `group_id` are the sub-groups under the given group. The assignable `group_id` are the sub-groups under the given group.
......
...@@ -99,7 +99,7 @@ POST /projects/:id/protected_environments ...@@ -99,7 +99,7 @@ POST /projects/:id/protected_environments
```shell ```shell
curl --header 'Content-Type: application/json' --request POST \ curl --header 'Content-Type: application/json' --request POST \
--data '{"name": "production", "deploy_access_levels": [{"group_id": 9899826}]}' \ --data '{"name": "production", "deploy_access_levels": [{"group_id": 9899826}], "approval_rules": [{"group_id": 134}, {"group_id": 135, "required_approvals": 2}]}' \
--header "PRIVATE-TOKEN: <your_access_token>" \ --header "PRIVATE-TOKEN: <your_access_token>" \
"https://gitlab.example.com/api/v4/projects/22034114/protected_environments" "https://gitlab.example.com/api/v4/projects/22034114/protected_environments"
``` ```
...@@ -110,8 +110,9 @@ curl --header 'Content-Type: application/json' --request POST \ ...@@ -110,8 +110,9 @@ curl --header 'Content-Type: application/json' --request POST \
| `name` | string | yes | The name of the environment. | | `name` | string | yes | The name of the environment. |
| `deploy_access_levels` | array | yes | Array of access levels allowed to deploy, with each described by a hash. | | `deploy_access_levels` | array | yes | Array of access levels allowed to deploy, with each described by a hash. |
| `required_approval_count` | integer | no | The number of approvals required to deploy to this environment. This is part of Deployment Approvals, which isn't yet available for use. For details, see [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/343864). | | `required_approval_count` | integer | no | The number of approvals required to deploy to this environment. This is part of Deployment Approvals, which isn't yet available for use. For details, see [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/343864). |
| `approval_rules` | array | no | Array of access levels allowed to approve, with each described by a hash. See [Multiple approval rules](../ci/environments/deployment_approvals.md#multiple-approval-rules) for more information. |
Elements in the `deploy_access_levels` array should be one of `user_id`, `group_id` or Elements in the `deploy_access_levels` and `approval_rules` array should be one of `user_id`, `group_id` or
`access_level`, and take the form `{user_id: integer}`, `{group_id: integer}` or `access_level`, and take the form `{user_id: integer}`, `{group_id: integer}` or
`{access_level: integer}`. `{access_level: integer}`.
Each user must have access to the project and each group must [have this project shared](../user/project/members/share_project_with_groups.md). Each user must have access to the project and each group must [have this project shared](../user/project/members/share_project_with_groups.md).
...@@ -129,7 +130,23 @@ Example response: ...@@ -129,7 +130,23 @@ Example response:
"group_id": 9899826 "group_id": 9899826
} }
], ],
"required_approval_count": 0 "required_approval_count": 0,
"approval_rules": [
{
"user_id": null,
"group_id": 134,
"access_level": null,
"access_level_description": "qa-group",
"required_approvals": 1
},
{
"user_id": null,
"group_id": 135,
"access_level": null,
"access_level_description": "security-group",
"required_approvals": 2
}
]
} }
``` ```
......
...@@ -52,6 +52,19 @@ Example: ...@@ -52,6 +52,19 @@ Example:
### Require approvals for a protected environment ### Require approvals for a protected environment
There are two ways to configure the approval requirements:
- [Unified approval setting](#unified-approval-setting) ... You can define who can execute **and** approve deployments.
This is useful when there is no separation of duties between executors and approvers in your oraganization.
- [Multiple approval rules](#multiple-approval-rules) ... You can define who can execute **or** approve deployments.
This is useful when there is a separation of duties between executors and approvers in your oraganization.
NOTE:
Multiple approval rules is a more flexible option than the unified approval setting, thus both configurations shouldn't
co-exist and multiple approval rules takes the precedence over the unified approval setting if it happens.
#### Unified approval setting
NOTE: NOTE:
At this time, it is not possible to require approvals for an existing protected environment. The workaround is to unprotect the environment and configure approvals when re-protecting the environment. At this time, it is not possible to require approvals for an existing protected environment. The workaround is to unprotect the environment and configure approvals when re-protecting the environment.
...@@ -77,6 +90,35 @@ NOTE: ...@@ -77,6 +90,35 @@ NOTE:
To protect, update, or unprotect an environment, you must have at least the To protect, update, or unprotect an environment, you must have at least the
Maintainer role. Maintainer role.
#### Multiple approval rules
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345678) in GitLab 14.10 with a flag named `deployment_approval_rules`. Disabled by default.
1. Using the [REST API](../../api/group_protected_environments.md#protect-an-environment).
1. `deploy_access_levels` represents which entity can execute the deployment job.
1. `approval_rules` represents which entity can approve the deployment job.
After this is configured, all jobs deploying to this environment automatically go into a blocked state and wait for approvals before running. Ensure that the number of required approvals is less than the number of users allowed to deploy.
Example:
```shell
curl --header 'Content-Type: application/json' --request POST \
--data '{"name": "production", "deploy_access_levels": [{"group_id": 138}], "approval_rules": [{"group_id": 134}, {"group_id": 135, "required_approvals": 2}]}' \
--header "PRIVATE-TOKEN: <your_access_token>" \
"https://gitlab.example.com/api/v4/groups/128/protected_environments"
```
With this setup:
- The operator group (`group_id: 138`) has permission to execute the deployment jobs to the `production` environment in the organization (`group_id: 128`).
- The QA tester group (`group_id: 134`) and security group (`group_id: 135`) have permission to approve the deployment jobs to the `production` environment in the organization (`group_id: 128`).
- Unless two approvals from security group and one approval from QA tester group have been collected, the operator group can't execute the deployment jobs.
NOTE:
To protect, update, or unprotect an environment, you must have at least the
Maintainer role.
## Approve or reject a deployment ## Approve or reject a deployment
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/342180/) in GitLab 14.9 > [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/342180/) in GitLab 14.9
...@@ -99,6 +141,10 @@ To approve or reject a deployment to a protected environment using the UI: ...@@ -99,6 +141,10 @@ To approve or reject a deployment to a protected environment using the UI:
1. In the deployment's row, select **Approval options** (**{thumb-up}**). 1. In the deployment's row, select **Approval options** (**{thumb-up}**).
1. Select **Approve** or **Reject**. 1. Select **Approve** or **Reject**.
NOTE:
This feature might not work as expected when [Multiple approval rules](#multiple-approval-rules) is configured.
See the [issue](https://gitlab.com/gitlab-org/gitlab/-/issues/355708) for planned improvement.
### Approve or reject a deployment using the API ### Approve or reject a deployment using the API
Prerequisites: Prerequisites:
...@@ -127,11 +173,14 @@ curl --data "status=approved&comment=Looks good to me" \ ...@@ -127,11 +173,14 @@ curl --data "status=approved&comment=Looks good to me" \
### Using the API ### Using the API
Use the [Deployments API](../../api/deployments.md) to see deployments. Use the [Deployments API](../../api/deployments.md#get-a-specific-deployment) to see deployments.
- The `status` field indicates if a deployment is blocked. - The `status` field indicates if a deployment is blocked.
- The `pending_approval_count` field indicates how many approvals are remaining to run a deployment. - When the [unified approval setting](#unified-approval-setting) is configured:
- The `approvals` field contains the deployment's approvals. - The `pending_approval_count` field indicates how many approvals are remaining to run a deployment.
- The `approvals` field contains the deployment's approvals.
- When the [multiple approval rules](#multiple-approval-rules) is configured:
- The `approval_summary` field contains the current approval status per rule.
## Related features ## Related features
......
...@@ -9,6 +9,7 @@ class ProtectedEnvironment < ApplicationRecord ...@@ -9,6 +9,7 @@ class ProtectedEnvironment < ApplicationRecord
has_many :approval_rules, class_name: 'ProtectedEnvironments::ApprovalRule', inverse_of: :protected_environment has_many :approval_rules, class_name: 'ProtectedEnvironments::ApprovalRule', inverse_of: :protected_environment
accepts_nested_attributes_for :deploy_access_levels, allow_destroy: true accepts_nested_attributes_for :deploy_access_levels, allow_destroy: true
accepts_nested_attributes_for :approval_rules, allow_destroy: true
validates :deploy_access_levels, length: { minimum: 1 } validates :deploy_access_levels, length: { minimum: 1 }
validates :name, presence: true validates :name, presence: true
......
...@@ -11,5 +11,7 @@ module ProtectedEnvironments ...@@ -11,5 +11,7 @@ module ProtectedEnvironments
has_many :deployment_approvals, class_name: 'Deployments::Approval', inverse_of: :approval_rule has_many :deployment_approvals, class_name: 'Deployments::Approval', inverse_of: :approval_rule
validates :access_level, allow_blank: true, inclusion: { in: ALLOWED_ACCESS_LEVELS } validates :access_level, allow_blank: true, inclusion: { in: ALLOWED_ACCESS_LEVELS }
validates :required_approvals,
numericality: { only_integer: true, greater_than_or_equal_to: 1, less_than_or_equal_to: 5 }
end end
end end
...@@ -8,6 +8,9 @@ module EE ...@@ -8,6 +8,9 @@ module EE
def process(build) def process(build)
if build.persisted_environment.try(:needs_approval?) if build.persisted_environment.try(:needs_approval?)
build.run_after_commit { |build| build.deployment&.block! } build.run_after_commit { |build| build.deployment&.block! }
# To populate the deployment job as manually executable (i.e. `Ci::Build#playable?`),
# we have to set `manual` to `ci_builds.when` as well as `ci_builds.status`.
build.when = 'manual' if ::Feature.enabled?(:deployment_approval_rules, build.project, default_enabled: :yaml)
return build.actionize! return build.actionize!
end end
......
...@@ -3,18 +3,23 @@ module ProtectedEnvironments ...@@ -3,18 +3,23 @@ module ProtectedEnvironments
class BaseService < ::BaseContainerService class BaseService < ::BaseContainerService
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
SANITIZABLE_KEYS = %i[deploy_access_levels_attributes approval_rules_attributes].freeze
protected protected
def sanitized_params def sanitized_params
params.dup.tap do |sanitized_params| params.dup.tap do |sanitized_params|
sanitized_params[:deploy_access_levels_attributes] = SANITIZABLE_KEYS.each do |key|
filter_valid_deploy_access_level_attributes(sanitized_params[:deploy_access_levels_attributes]) next unless sanitized_params.has_key?(key)
sanitized_params[key] = filter_valid_authorizable_attributes(sanitized_params[key])
end
end end
end end
private private
def filter_valid_deploy_access_level_attributes(attributes) def filter_valid_authorizable_attributes(attributes)
return unless attributes return unless attributes
attributes.select { |attribute| valid_attribute?(attribute) } attributes.select { |attribute| valid_attribute?(attribute) }
...@@ -51,7 +56,7 @@ module ProtectedEnvironments ...@@ -51,7 +56,7 @@ module ProtectedEnvironments
def qualified_user_ids def qualified_user_ids
strong_memoize(:qualified_user_ids) do strong_memoize(:qualified_user_ids) do
user_ids = params[:deploy_access_levels_attributes].each.with_object([]) do |attribute, user_ids| user_ids = all_sanitizable_params.each.with_object([]) do |attribute, user_ids|
user_ids << attribute[:user_id] if attribute[:user_id].present? user_ids << attribute[:user_id] if attribute[:user_id].present?
user_ids user_ids
end end
...@@ -64,5 +69,9 @@ module ProtectedEnvironments ...@@ -64,5 +69,9 @@ module ProtectedEnvironments
end.pluck_user_ids.to_set end.pluck_user_ids.to_set
end end
end end
def all_sanitizable_params
params.values_at(*SANITIZABLE_KEYS).flatten.compact
end
end end
end end
# frozen_string_literal: true
module API
module Entities
module Deployments
class ApprovalSummary < Grape::Entity
expose :rules, using: ::API::Entities::ProtectedEnvironments::ApprovalRuleForSummary
end
end
end
end
# frozen_string_literal: true
module API
module Entities
module ProtectedEnvironments
class ApprovalRule < Grape::Entity
expose :user_id
expose :group_id
expose :access_level
expose :humanize, as: :access_level_description
expose :required_approvals
end
end
end
end
# frozen_string_literal: true
module API
module Entities
module ProtectedEnvironments
class ApprovalRuleForSummary < ApprovalRule
expose :deployment_approvals, using: ::API::Entities::Deployments::Approval
end
end
end
end
...@@ -8,6 +8,18 @@ module API ...@@ -8,6 +8,18 @@ module API
feature_category :continuous_delivery feature_category :continuous_delivery
helpers do
params :protected_environment_approval_rules do
optional :approval_rules, as: :approval_rules_attributes, type: Array, desc: 'An array of users/groups allowed to approve/reject a deployment' do
optional :access_level, type: Integer, values: ::ProtectedEnvironments::ApprovalRule::ALLOWED_ACCESS_LEVELS
optional :user_id, type: Integer
optional :group_id, type: Integer
optional :required_approvals, type: Integer, default: 1, desc: 'The number of approvals required in this rule'
at_least_one_of :access_level, :user_id, :group_id
end
end
end
params do params do
requires :id, type: String, desc: 'The ID of a project' requires :id, type: String, desc: 'The ID of a project'
end end
...@@ -58,6 +70,8 @@ module API ...@@ -58,6 +70,8 @@ module API
optional :user_id, type: Integer optional :user_id, type: Integer
optional :group_id, type: Integer optional :group_id, type: Integer
end end
use :protected_environment_approval_rules
end end
post ':id/protected_environments' do post ':id/protected_environments' do
protected_environment = user_project.protected_environments.find_by_name(params[:name]) protected_environment = user_project.protected_environments.find_by_name(params[:name])
...@@ -145,6 +159,8 @@ module API ...@@ -145,6 +159,8 @@ module API
optional :user_id, type: Integer optional :user_id, type: Integer
optional :group_id, type: Integer optional :group_id, type: Integer
end end
use :protected_environment_approval_rules
end end
post ':id/protected_environments' do post ':id/protected_environments' do
protected_environment = user_group.protected_environments.find_by_name(params[:name]) protected_environment = user_group.protected_environments.find_by_name(params[:name])
......
...@@ -9,6 +9,7 @@ module EE ...@@ -9,6 +9,7 @@ module EE
prepended do prepended do
expose :pending_approval_count expose :pending_approval_count
expose :approvals, using: ::API::Entities::Deployments::Approval expose :approvals, using: ::API::Entities::Deployments::Approval
expose :approval_summary, using: ::API::Entities::Deployments::ApprovalSummary
end end
end end
end end
......
...@@ -7,6 +7,7 @@ module EE ...@@ -7,6 +7,7 @@ module EE
expose :name expose :name
expose :deploy_access_levels, using: ::API::Entities::ProtectedRefAccess expose :deploy_access_levels, using: ::API::Entities::ProtectedRefAccess
expose :required_approval_count expose :required_approval_count
expose :approval_rules, using: ::API::Entities::ProtectedEnvironments::ApprovalRule
end end
end end
end end
......
...@@ -7,7 +7,8 @@ ...@@ -7,7 +7,8 @@
"properties": { "properties": {
"name": { "type": "string" }, "name": { "type": "string" },
"deploy_access_levels": { "type": "array", "items": { "$ref": "protected_ref_access.json" } }, "deploy_access_levels": { "type": "array", "items": { "$ref": "protected_ref_access.json" } },
"required_approval_count": { "type": "integer" } "required_approval_count": { "type": "integer" },
"approval_rules": { "type": "array", "items": { "$ref": "protected_environment_approval_rule.json" } }
}, },
"additionalProperties": false "additionalProperties": false
} }
{
"type": "object",
"required": [ "access_level_description" ],
"properties": {
"access_level": { "type": ["integer", "null"] },
"access_level_description": { "type": ["string", "null"] },
"user_id": { "type": ["integer", "null"] },
"group_id": { "type": ["integer", "null"] },
"required_approvals": { "type": ["integer"] }
},
"additionalProperties": false
}
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Entities::Deployments::ApprovalSummary do
subject { described_class.new(approval_summary).as_json }
let(:deployment) { build(:deployment) }
let(:approval_summary) { deployment.approval_summary }
it 'exposes correct attributes' do
expect(subject.keys).to contain_exactly(:rules)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Entities::ProtectedEnvironments::ApprovalRuleForSummary do
subject { described_class.new(approval_rule).as_json }
let(:approval_rule) { build(:protected_environment_approval_rule) }
it 'exposes correct attributes' do
expect(subject.keys).to contain_exactly(:user_id, :group_id, :access_level, :access_level_description,
:required_approvals, :deployment_approvals)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::Entities::ProtectedEnvironments::ApprovalRule do
subject { described_class.new(approval_rule).as_json }
let(:approval_rule) { build(:protected_environment_approval_rule) }
it 'exposes correct attributes' do
expect(subject.keys).to contain_exactly(:user_id, :group_id, :access_level, :access_level_description,
:required_approvals)
end
end
...@@ -10,8 +10,9 @@ RSpec.describe ::EE::API::Entities::DeploymentExtended do ...@@ -10,8 +10,9 @@ RSpec.describe ::EE::API::Entities::DeploymentExtended do
before do before do
stub_licensed_features(protected_environments: true) stub_licensed_features(protected_environments: true)
create(:protected_environment, project_id: deployment.environment.project_id, name: deployment.environment.name, required_approval_count: 2) protected_environment = create(:protected_environment, project_id: deployment.environment.project_id, name: deployment.environment.name, required_approval_count: 2)
create(:deployment_approval, :approved, deployment: deployment) create(:deployment_approval, :approved, deployment: deployment)
create(:protected_environment_approval_rule, :maintainer_access, protected_environment: protected_environment, required_approvals: 1)
end end
it 'includes fields from deployment entity' do it 'includes fields from deployment entity' do
...@@ -26,5 +27,9 @@ RSpec.describe ::EE::API::Entities::DeploymentExtended do ...@@ -26,5 +27,9 @@ RSpec.describe ::EE::API::Entities::DeploymentExtended do
expect(subject[:approvals].length).to eq(1) expect(subject[:approvals].length).to eq(1)
expect(subject.dig(:approvals, 0, :status)).to eq("approved") expect(subject.dig(:approvals, 0, :status)).to eq("approved")
end end
it 'includes approval summary' do
expect(subject[:approval_summary][:rules].first[:required_approvals]).to eq(1)
end
end end
end end
...@@ -8,4 +8,11 @@ RSpec.describe ProtectedEnvironments::ApprovalRule do ...@@ -8,4 +8,11 @@ RSpec.describe ProtectedEnvironments::ApprovalRule do
it_behaves_like 'authorizable for protected environments', it_behaves_like 'authorizable for protected environments',
factory_name: :protected_environment_approval_rule factory_name: :protected_environment_approval_rule
describe 'validation' do
it 'has a limit on required_approvals' do
is_expected.to validate_numericality_of(:required_approvals)
.only_integer.is_greater_than_or_equal_to(1).is_less_than_or_equal_to(5)
end
end
end end
...@@ -3,16 +3,37 @@ ...@@ -3,16 +3,37 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe API::Deployments do RSpec.describe API::Deployments do
let_it_be_with_refind(:organization) { create(:group) }
let_it_be_with_refind(:project) { create(:project, :repository, group: organization) }
let(:user) { create(:user) } let(:user) { create(:user) }
let!(:project) { create(:project, :repository) }
let!(:environment) { create(:environment, project: project) } let!(:environment) { create(:environment, project: project) }
before do before do
stub_licensed_features(protected_environments: true) stub_licensed_features(protected_environments: true)
end end
shared_context 'group-level protected environments with multiple approval rules' do
let!(:security_group) { create(:group, name: 'security-group', parent: organization) }
let!(:security_user) { create(:user) }
before do
security_group.add_developer(security_user)
organization.add_reporter(security_user)
end
let!(:group_protected_environment) do
create(:protected_environment, :group_level, group: organization, name: environment.tier)
end
let!(:approval_rule) do
create(:protected_environment_approval_rule, group: security_group,
protected_environment: group_protected_environment, required_approvals: 2)
end
end
describe 'GET /projects/:id/deployments/:id' do describe 'GET /projects/:id/deployments/:id' do
let(:deployment) { create(:deployment, :blocked, project: project) } let(:deployment) { create(:deployment, :blocked, project: project, environment: environment) }
before do before do
create(:deployment_approval, :approved, deployment: deployment) create(:deployment_approval, :approved, deployment: deployment)
...@@ -25,6 +46,26 @@ RSpec.describe API::Deployments do ...@@ -25,6 +46,26 @@ RSpec.describe API::Deployments do
expect(response).to have_gitlab_http_status(:success) expect(response).to have_gitlab_http_status(:success)
expect(response).to match_response_schema('public_api/v4/deployment_extended', dir: 'ee') expect(response).to match_response_schema('public_api/v4/deployment_extended', dir: 'ee')
end end
context 'with multiple approval rules' do
include_context 'group-level protected environments with multiple approval rules'
let!(:deployment_approval) do
create(:deployment_approval, :approved, user: security_user, approval_rule: approval_rule,
deployment: deployment)
end
it 'has approval summary' do
get api("/projects/#{project.id}/deployments/#{deployment.id}", user)
expect(response).to have_gitlab_http_status(:success)
expect(json_response['approval_summary']['rules'].count).to eq(1)
expect(json_response['approval_summary']['rules'].first['required_approvals']).to eq(2)
expect(json_response['approval_summary']['rules'].first['deployment_approvals'].count).to eq(1)
expect(json_response['approval_summary']['rules'].first['deployment_approvals'].first["user"]["id"])
.to eq(security_user.id)
end
end
end end
describe 'POST /projects/:id/deployments' do describe 'POST /projects/:id/deployments' do
...@@ -283,6 +324,35 @@ RSpec.describe API::Deployments do ...@@ -283,6 +324,35 @@ RSpec.describe API::Deployments do
end end
end end
context 'with multiple approval rules' do
include_context 'group-level protected environments with multiple approval rules'
it 'creates an approval' do
expect { post(api(path, security_user), params: { status: 'approved' }) }
.to change { Deployments::Approval.count }.by(1)
expect(response).to have_gitlab_http_status(:success)
expect(json_response['status']).to eq('approved')
end
it 'creates an approval when the user represents the group' do
expect { post(api(path, security_user), params: { status: 'approved', represented_as: 'security' }) }
.to change { Deployments::Approval.count }.by(1)
expect(response).to have_gitlab_http_status(:success)
expect(json_response['status']).to eq('approved')
end
it 'does not create an approval when the user does not represent the group' do
expect { post(api(path, security_user), params: { status: 'approved', represented_as: 'qa' }) }
.not_to change { Deployments::Approval.count }
expect(response).to have_gitlab_http_status(:bad_request)
expect(response.body).to include("You don't have permission to review this deployment. " \
"Contact the project or group owner for help.")
end
end
context 'and user is not authorized to update deployment' do context 'and user is not authorized to update deployment' do
include_examples 'not created', response_status: :bad_request, message: "You don't have permission to review this deployment. Contact the project or group owner for help." include_examples 'not created', response_status: :bad_request, message: "You don't have permission to review this deployment. Contact the project or group owner for help."
end end
......
...@@ -11,9 +11,14 @@ RSpec.describe API::ProtectedEnvironments do ...@@ -11,9 +11,14 @@ RSpec.describe API::ProtectedEnvironments do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:protected_environment_name) { 'production' } let(:protected_environment_name) { 'production' }
before do let!(:project_protected_environment) do
create(:protected_environment, :maintainers_can_deploy, :project_level, project: project, name: protected_environment_name, required_approval_count: 1) create(:protected_environment, :maintainers_can_deploy, :project_level, project: project,
create(:protected_environment, :maintainers_can_deploy, :group_level, group: group, name: protected_environment_name, required_approval_count: 2) name: protected_environment_name, required_approval_count: 1)
end
let!(:group_protected_environment) do
create(:protected_environment, :maintainers_can_deploy, :group_level, group: group,
name: protected_environment_name, required_approval_count: 2)
end end
shared_examples 'requests for non-maintainers' do shared_examples 'requests for non-maintainers' do
...@@ -67,6 +72,24 @@ RSpec.describe API::ProtectedEnvironments do ...@@ -67,6 +72,24 @@ RSpec.describe API::ProtectedEnvironments do
expect(json_response['required_approval_count']).to eq(1) expect(json_response['required_approval_count']).to eq(1)
end end
context 'with multiple approval rules' do
before do
create(:protected_environment_approval_rule, :maintainer_access,
protected_environment: project_protected_environment, required_approvals: 3)
end
it 'returns the protected environment' do
request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/protected_environment', dir: 'ee')
expect(json_response['name']).to eq(protected_environment_name)
expect(json_response['deploy_access_levels'][0]['access_level']).to eq(::Gitlab::Access::MAINTAINER)
expect(json_response['approval_rules'][0]['access_level']).to eq(::Gitlab::Access::MAINTAINER)
expect(json_response['approval_rules'][0]['required_approvals']).to eq(3)
end
end
context 'when protected environment does not exist' do context 'when protected environment does not exist' do
let(:requested_environment_name) { 'unknown' } let(:requested_environment_name) { 'unknown' }
...@@ -129,6 +152,21 @@ RSpec.describe API::ProtectedEnvironments do ...@@ -129,6 +152,21 @@ RSpec.describe API::ProtectedEnvironments do
expect(response).to have_gitlab_http_status(:conflict) expect(response).to have_gitlab_http_status(:conflict)
end end
it 'protects the environment and require approvals' do
deployer = create(:user)
project.add_developer(deployer)
post api_url, params: { name: 'staging', deploy_access_levels: [{ user_id: deployer.id }],
approval_rules: [{ access_level: Gitlab::Access::MAINTAINER }] }
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('public_api/v4/protected_environment', dir: 'ee')
expect(json_response['name']).to eq('staging')
expect(json_response['deploy_access_levels'].first['user_id']).to eq(deployer.id)
expect(json_response['approval_rules'].count).to eq(1)
expect(json_response['approval_rules'].first['access_level']).to eq(Gitlab::Access::MAINTAINER)
end
context 'without deploy_access_levels' do context 'without deploy_access_levels' do
it_behaves_like '400 response' do it_behaves_like '400 response' do
let(:request) { post api_url, params: { name: 'staging' } } let(:request) { post api_url, params: { name: 'staging' } }
...@@ -212,6 +250,24 @@ RSpec.describe API::ProtectedEnvironments do ...@@ -212,6 +250,24 @@ RSpec.describe API::ProtectedEnvironments do
expect(json_response['required_approval_count']).to eq(2) expect(json_response['required_approval_count']).to eq(2)
end end
context 'with multiple approval rules' do
before do
create(:protected_environment_approval_rule, :maintainer_access,
protected_environment: group_protected_environment, required_approvals: 3)
end
it 'returns the protected environment' do
request
expect(response).to have_gitlab_http_status(:ok)
expect(response).to match_response_schema('public_api/v4/protected_environment', dir: 'ee')
expect(json_response['name']).to eq(protected_environment_name)
expect(json_response['deploy_access_levels'][0]['access_level']).to eq(::Gitlab::Access::MAINTAINER)
expect(json_response['approval_rules'][0]['access_level']).to eq(::Gitlab::Access::MAINTAINER)
expect(json_response['approval_rules'][0]['required_approvals']).to eq(3)
end
end
context 'when protected environment does not exist' do context 'when protected environment does not exist' do
let(:requested_environment_name) { 'unknown' } let(:requested_environment_name) { 'unknown' }
...@@ -277,6 +333,21 @@ RSpec.describe API::ProtectedEnvironments do ...@@ -277,6 +333,21 @@ RSpec.describe API::ProtectedEnvironments do
expect(json_response['deploy_access_levels'].first['access_level']).to eq(Gitlab::Access::MAINTAINER) expect(json_response['deploy_access_levels'].first['access_level']).to eq(Gitlab::Access::MAINTAINER)
end end
it 'protects the environment and require approvals' do
deployer = create(:user)
project.add_developer(deployer)
post api_url, params: { name: 'staging', deploy_access_levels: [{ access_level: Gitlab::Access::MAINTAINER }],
approval_rules: [{ access_level: Gitlab::Access::DEVELOPER }] }
expect(response).to have_gitlab_http_status(:created)
expect(response).to match_response_schema('public_api/v4/protected_environment', dir: 'ee')
expect(json_response['name']).to eq('staging')
expect(json_response['deploy_access_levels'].first['access_level']).to eq(Gitlab::Access::MAINTAINER)
expect(json_response['approval_rules'].count).to eq(1)
expect(json_response['approval_rules'].first['access_level']).to eq(Gitlab::Access::DEVELOPER)
end
it 'returns 409 error if environment is already protected' do it 'returns 409 error if environment is already protected' do
deployer = create(:user) deployer = create(:user)
group.add_developer(deployer) group.add_developer(deployer)
......
...@@ -81,6 +81,20 @@ RSpec.describe Ci::ProcessBuildService, '#execute' do ...@@ -81,6 +81,20 @@ RSpec.describe Ci::ProcessBuildService, '#execute' do
include_examples 'blocked deployment' include_examples 'blocked deployment'
it 'sets manual to build.when' do
expect { subject }.to change { ci_build.reload.when }.to('manual')
end
context 'when deployment_approval_rules feature flag is disabled' do
before do
stub_feature_flags(deployment_approval_rules: false)
end
it 'does not set ci_builds.when' do
expect { subject }.not_to change { ci_build.reload.when }
end
end
context 'and the build is schedulable' do context 'and the build is schedulable' do
let(:ci_build) { create(:ci_build, :created, :schedulable, environment: environment.name, user: user, project: project) } let(:ci_build) { create(:ci_build, :created, :schedulable, environment: environment.name, user: user, project: project) }
......
...@@ -26,6 +26,11 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do ...@@ -26,6 +26,11 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do
{ group_id: group.id }, { group_id: group.id },
{ group_id: other_group.id }, { group_id: other_group.id },
{ group_id: child_group.id } { group_id: child_group.id }
],
approval_rules_attributes: [
{ group_id: group.id },
{ group_id: other_group.id },
{ group_id: child_group.id }
] ]
} }
end end
...@@ -35,6 +40,10 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do ...@@ -35,6 +40,10 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do
deploy_access_levels_attributes: [ deploy_access_levels_attributes: [
{ group_id: group.id }, { group_id: group.id },
{ group_id: child_group.id } { group_id: child_group.id }
],
approval_rules_attributes: [
{ group_id: group.id },
{ group_id: child_group.id }
] ]
) )
end end
...@@ -48,6 +57,10 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do ...@@ -48,6 +57,10 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do
deploy_access_levels_attributes: [ deploy_access_levels_attributes: [
{ group_id: group.id }, { group_id: group.id },
{ group_id: linked_group.id } { group_id: linked_group.id }
],
approval_rules_attributes: [
{ group_id: group.id },
{ group_id: linked_group.id }
] ]
} }
end end
...@@ -57,7 +70,11 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do ...@@ -57,7 +70,11 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do
deploy_access_levels_attributes: [ deploy_access_levels_attributes: [
{ group_id: group.id }, { group_id: group.id },
{ group_id: linked_group.id } { group_id: linked_group.id }
] ],
approval_rules_attributes: [
{ group_id: group.id },
{ group_id: linked_group.id }
]
) )
end end
end end
...@@ -69,6 +86,11 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do ...@@ -69,6 +86,11 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do
{ group_id: group.id }, { group_id: group.id },
{ group_id: other_group.id, '_destroy' => 1 }, { group_id: other_group.id, '_destroy' => 1 },
{ group_id: child_group.id } { group_id: child_group.id }
],
approval_rules_attributes: [
{ group_id: group.id },
{ group_id: other_group.id, '_destroy' => 1 },
{ group_id: child_group.id }
] ]
} }
end end
...@@ -79,6 +101,11 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do ...@@ -79,6 +101,11 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do
{ group_id: group.id }, { group_id: group.id },
{ group_id: other_group.id, '_destroy' => 1 }, { group_id: other_group.id, '_destroy' => 1 },
{ group_id: child_group.id } { group_id: child_group.id }
],
approval_rules_attributes: [
{ group_id: group.id },
{ group_id: other_group.id, '_destroy' => 1 },
{ group_id: child_group.id }
] ]
) )
end end
...@@ -93,6 +120,12 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do ...@@ -93,6 +120,12 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do
{ user_id: group_developer.id }, { user_id: group_developer.id },
{ user_id: other_group_maintainer.id }, { user_id: other_group_maintainer.id },
{ user_id: child_group_maintainer.id } { user_id: child_group_maintainer.id }
],
approval_rules_attributes: [
{ user_id: group_maintainer.id },
{ user_id: group_developer.id },
{ user_id: other_group_maintainer.id },
{ user_id: child_group_maintainer.id }
] ]
} }
end end
...@@ -113,6 +146,9 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do ...@@ -113,6 +146,9 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do
is_expected.to eq( is_expected.to eq(
deploy_access_levels_attributes: [ deploy_access_levels_attributes: [
{ user_id: group_maintainer.id } { user_id: group_maintainer.id }
],
approval_rules_attributes: [
{ user_id: group_maintainer.id }
] ]
) )
end end
...@@ -125,6 +161,12 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do ...@@ -125,6 +161,12 @@ RSpec.describe ProtectedEnvironments::BaseService, '#execute' do
{ user_id: group_developer.id, '_destroy' => 1 }, { user_id: group_developer.id, '_destroy' => 1 },
{ user_id: other_group_maintainer.id, '_destroy' => 1 }, { user_id: other_group_maintainer.id, '_destroy' => 1 },
{ user_id: child_group_maintainer.id, '_destroy' => 1 } { user_id: child_group_maintainer.id, '_destroy' => 1 }
],
approval_rules_attributes: [
{ user_id: group_maintainer.id },
{ user_id: group_developer.id, '_destroy' => 1 },
{ user_id: other_group_maintainer.id, '_destroy' => 1 },
{ user_id: child_group_maintainer.id, '_destroy' => 1 }
] ]
} }
end end
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment