diff --git a/app/helpers/notify_helper.rb b/app/helpers/notify_helper.rb index c0ba93f4a30..455b56e92fc 100644 --- a/app/helpers/notify_helper.rb +++ b/app/helpers/notify_helper.rb @@ -20,4 +20,9 @@ module NotifyHelper (source.description || default_description).truncate(200, separator: ' ') end + + def merge_request_approved_description(merge_request, approved_by) + format(s_('Notify|%{mr_highlight}Merge request%{highlight_end} %{mr_link} %{approved_highlight}was approved by%{highlight_end} %{approver_avatar} %{approver_link}').html_safe, mr_highlight: ''.html_safe, highlight_end: ''.html_safe, mr_link: link_to(merge_request.to_reference, merge_request_url(merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none").html_safe, approved_highlight: ''.html_safe, approver_avatar: content_tag(:img, nil, height: "24", src: avatar_icon_for_user(approved_by, 24, only_path: false), + style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar", class: "avatar").html_safe, approver_link: link_to(approved_by.name, user_url(approved_by), style: "color:#333333;text-decoration:none;", class: "muted").html_safe) + end end diff --git a/app/views/notify/approved_merge_request_email.html.haml b/app/views/notify/approved_merge_request_email.html.haml index 28da1182d49..68823fbe6b5 100644 --- a/app/views/notify/approved_merge_request_email.html.haml +++ b/app/views/notify/approved_merge_request_email.html.haml @@ -78,10 +78,11 @@ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;padding-right:5px;" } %img{ alt: "✓", height: "13", src: image_url('mailers/ci_pipeline_notif_v1/icon-check-green-inverted.gif'), style: "display:block;", width: "13" }/ %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;vertical-align:middle;color:#ffffff;text-align:center;" } - - if @merge_request.respond_to? :approvals_required - %span Merge request was approved (#{@merge_request.approvals.count}/#{@merge_request.approvals_required}) - - else - %span Merge request was approved + %span + - if @merge_request.respond_to? :approvals_required + = s_('Notify|Merge request was approved (%{approvals}/%{required_approvals})') % { approvals: @merge_request.approvals.count, required_approvals: @merge_request.approvals_required } + - else + = s_('Notify|Merge request was approved') %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }   @@ -92,12 +93,7 @@ %tr{ style: 'width:100%;' } %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;text-align:center;" } %img{ src: image_url('mailers/approval/icon-merge-request-gray.gif'), style: "height:18px;width:18px;margin-bottom:-4px;", alt: "Merge request icon" } - %span{ style: "font-weight: 600;color:#333333;" } Merge request - %a{ href: merge_request_url(@merge_request), style: "font-weight: 600;color:#3777b0;text-decoration:none" }= @merge_request.to_reference - %span was approved by - %img.avatar{ height: "24", src: avatar_icon_for_user(@approved_by, 24, only_path: false), style: "border-radius:12px;margin:-7px 0 -7px 3px;", width: "24", alt: "Avatar" }/ - %a.muted{ href: user_url(@approved_by), style: "color:#333333;text-decoration:none;" } - = @approved_by.name + = merge_request_approved_description(@merge_request, @approved_by) %tr.spacer %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;height:18px;font-size:18px;line-height:18px;" }   @@ -106,7 +102,7 @@ %table.info{ border: "0", cellpadding: "0", cellspacing: "0", style: "width:100%;" } %tbody %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" } Project + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;" }= _("Project") %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;" } - namespace_name = @project.group ? @project.group.name : @project.namespace.owner.name - namespace_url = @project.group ? group_url(@project.group) : user_url(@project.namespace.owner) @@ -116,7 +112,7 @@ %a.muted{ href: project_url(@project), style: "color:#333333;text-decoration:none;" } = @project.name %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Branch + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _("Branch") %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody @@ -127,7 +123,7 @@ %span.muted{ style: "color:#333333;text-decoration:none;" } = @merge_request.source_branch %tr - %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" } Author + %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;border-top:1px solid #ededed;" }= _("Author") %td{ style: "font-family:'Helvetica Neue',Helvetica,Arial,sans-serif;font-size:15px;line-height:1.4;color:#8c8c8c;font-weight:300;padding:14px 0;margin:0;color:#333333;font-weight:400;width:75%;padding-left:5px;border-top:1px solid #ededed;" } %table.img{ border: "0", cellpadding: "0", cellspacing: "0", style: "border-collapse:collapse;" } %tbody diff --git a/doc/ci/cloud_services/azure/index.md b/doc/ci/cloud_services/azure/index.md new file mode 100644 index 00000000000..901b36afde6 --- /dev/null +++ b/doc/ci/cloud_services/azure/index.md @@ -0,0 +1,157 @@ +--- +stage: Verify +group: Pipeline Authoring +info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments +--- + +# Configure OpenID Connect in Azure to retrieve temporary credentials + +This tutorial demonstrates how to use a JSON web token (JWT) in a GitLab CI/CD job +to retrieve temporary credentials from Azure without needing to store secrets. + +To get started, configure OpenID Connect (OIDC) for identity federation between GitLab and Azure. +For more information on using OIDC with GitLab, read [Connect to cloud services](../index.md). + +Prerequisites: + +- Access to an existing Azure Subscription with `Owner` access level. +- Access to the corresponding Azure Active Directory Tenant with at least the `Application Developer` access level. +- A local installation of the [Azure CLI](https://docs.microsoft.com/cli/azure/install-azure-cli). + Alternatively, you can follow all the steps below with the [Azure Cloud Shell](https://shell.azure.com/). +- A GitLab project. + +To complete this tutorial: + +1. [Create Azure AD application and service principal](#create-azure-ad-application-and-service-principal). +1. [Create Azure AD federated identity credentials](#create-azure-ad-federated-identity-credentials). +1. [Grant permissions for the service principal](#grant-permissions-for-the-service-principal). +1. [Retrieve a temporary credential](#retrieve-a-temporary-credential). + +For more information, review Azure's documentation on [Workload identity federation](https://docs.microsoft.com/azure/active-directory/develop/workload-identity-federation). + +## Create Azure AD application and service principal + +To create an [Azure AD application](https://docs.microsoft.com/cli/azure/ad/app?view=azure-cli-latest#az-ad-app-create) +and service principal: + +1. In the Azure CLI, create the AD application: + + ```shell + appId=$(az ad app create --display-name gitlab-oidc --query appId -otsv) + ``` + + Save the `appId` (Application client ID) output, as you need it later + to configure your GitLab CI/CD pipeline. + +1. Create a corresponding [Service Principal](https://docs.microsoft.com/cli/azure/ad/sp?view=azure-cli-latest#az-ad-sp-create): + + ```shell + az ad sp create --id $appId --query appId -otsv + ``` + +Instead of the Azure CLI, you can [use the Azure Portal to create these resources](https://docs.microsoft.com/azure/active-directory/develop/howto-create-service-principal-portal). + +## Create Azure AD federated identity credentials + +To create the federated identity credentials for the above Azure AD application: + +```shell +objectId=$(az ad app show --id $appId --query id -otsv) + +cat < body.json +{ + "name": "gitlab-federated-identity", + "issuer": "https://gitlab.example.com", + "subject": "project_path:/:ref_type:branch:ref:", + "description": "GitLab service account federated identity", + "audiences": [ + "https://gitlab.example.com" + ] +} +EOF + +az rest --method POST --uri "https://graph.microsoft.com/beta/applications/$objectId/federatedIdentityCredentials" --body @body.json +``` + +For issues related to the values of `issuer`, `subject` or `audiences`, see the +[troubleshooting](#troubleshooting) details. + +Optionally, you can now verify the Azure AD application and the Azure AD federated +identity credentials from the Azure Portal: + +1. Open the [Azure Active Directory App Registration](https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps) + view and select the appropriate app registration by searching for the display name `gitlab-oidc`. +1. On the overview page you can verify details like the `Application (client) ID`, + `Object ID`, and `Tenant ID`. +1. Under `Certificates & secrets`, go to `Federated credentials` to review your + Azure AD federated identity credentials. + +## Grant permissions for the service principal + +After you create the credentials, use [`role assignment`](https://docs.microsoft.com/cli/azure/role/assignment?view=azure-cli-latest#az-role-assignment-create) +to grant permissions to the above service principal to access to Azure resources: + +```shell +az role assignment create --assignee $appId --role Reader --scope /subscriptions/ +``` + +You can find your subscription ID in: + +- The [Azure Portal](https://docs.microsoft.com/azure/azure-portal/get-subscription-tenant-id#find-your-azure-subscription). +- The [Azure CLI](https://docs.microsoft.com/cli/azure/manage-azure-subscriptions-azure-cli#get-the-active-subscription). + +## Retrieve a temporary credential + +After you configure the Azure AD application and federated identity credentials, +the CI/CD job can retrieve a temporary credential by using the [Azure CLI](https://docs.microsoft.com/cli/azure/reference-index?view=azure-cli-latest#az-login): + +```yaml +default: + image: mcr.microsoft.com/azure-cli:latest + +variables: + AZURE_CLIENT_ID: "" + AZURE_TENANT_ID: "" + +auth: + script: + - az login --service-principal -u $AZURE_CLIENT_ID -t $AZURE_TENANT_ID --federated-token $CI_JOB_JWT_V2 + - az account show +``` + +The CI/CD variables are: + +- `AZURE_CLIENT_ID`: The [application client ID you saved earlier](#create-azure-ad-application-and-service-principal). +- `AZURE_TENANT_ID`: Your Azure Active Directory. You can + [find it by using the Azure CLI or Azure Portal](https://docs.microsoft.com/azure/active-directory/fundamentals/active-directory-how-to-find-tenant). +- `CI_JOB_JWT_V2`: The JSON web token is a [predefined CI/CD variable](../../variables/predefined_variables.md). + +## Troubleshooting + +### "No matching federated identity record found" + +If you receive the error `ERROR: AADSTS70021: No matching federated identity record found for presented assertion.` +you should verify: + +- The `Issuer` defined in the Azure AD federated identity credentials, for example + `https://gitlab.com` or your own GitLab URL. +- The `Subject identifier` defined in the Azure AD federated identity credentials, + for example `project_path:/:ref_type:branch:ref:`. + - For the `gitlab-group/gitlab-project` project and `main` branch it would be: + `project_path:gitlab-group/gitlab-project:ref_type:branch:ref:main`. + - The correct values of `mygroup` and `myproject` can be retrieved by checking the URL + when accessing your GitLab project or by selecting the **Clone** option in the project. +- The `Audience` defined in the Azure AD federated identity credentials, for example `https://gitlab.com` + or your own GitLab URL. + +You can review these settings, as well as your `AZURE_CLIENT_ID` and `AZURE_TENANT_ID` +CI/CD variables, from the Azure Portal: + +1. Open the [Azure Active Directory App Registration](https://portal.azure.com/#view/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/~/RegisteredApps) + view and select the appropriate app registration by searching for the display name `gitlab-oidc`. +1. On the overview page you can verify details like the `Application (client) ID`, + `Object ID`, and `Tenant ID`. +1. Under `Certificates & secrets`, go to `Federated credentials` to review your + Azure AD federated identity credentials. + +Review [Connect to cloud services](../index.md) for further details. diff --git a/doc/ci/cloud_services/index.md b/doc/ci/cloud_services/index.md index 1493a930099..b460af2d96e 100644 --- a/doc/ci/cloud_services/index.md +++ b/doc/ci/cloud_services/index.md @@ -16,7 +16,7 @@ GitLab CI/CD supports [OpenID Connect (OIDC)](https://openid.net/connect/faq/) t - Account on GitLab. - Access to a cloud provider that supports OIDC to configure authorization and create roles. -The original implementation of `CI_JOB_JWT` supports [HashiCorp Vault integration](../examples/authenticating-with-hashicorp-vault/). The updated implementation of `CI_JOB_JWT_V2` supports additional cloud providers with OIDC including AWS, GCP, and Vault. +The original implementation of `CI_JOB_JWT` supports [HashiCorp Vault integration](../examples/authenticating-with-hashicorp-vault/). The updated implementation of `CI_JOB_JWT_V2` supports additional cloud providers with OIDC including AWS, Azure, GCP, and Vault. NOTE: Configuring OIDC enables JWT token access to the target environments for all pipelines. @@ -38,7 +38,7 @@ The `CI_JOB_JWT_V2` variable is under development [(alpha)](../../policy/alpha-b ## How it works -Each job has a JSON web token (JWT) provided as a CI/CD [predefined variable](../variables/predefined_variables.md) named `CI_JOB_JWT` or `CI_JOB_JWT_V2`. This JWT can be used to authenticate with the OIDC-supported cloud provider such as AWS, GCP, or Vault. +Each job has a JSON web token (JWT) provided as a CI/CD [predefined variable](../variables/predefined_variables.md) named `CI_JOB_JWT` or `CI_JOB_JWT_V2`. This JWT can be used to authenticate with the OIDC-supported cloud provider such as AWS, Azure, GCP, or Vault. The following fields are included in the JWT: @@ -112,7 +112,7 @@ sequenceDiagram ``` -1. Create an OIDC identity provider in the cloud (for example, AWS, GCP, Vault). +1. Create an OIDC identity provider in the cloud (for example, AWS, Azure, GCP, Vault). 1. Create a conditional role in the cloud service that filters to a group, project, branch, or tag. 1. The CI/CD job includes a predefined variable `CI_JOB_JWT_V2` that is a JWT token. You can use this token for authorization with your cloud API. 1. The cloud verifies the token, validates the conditional role from the payload, and returns a temporary credential. @@ -138,4 +138,5 @@ To configure the trust between GitLab and OIDC, you must create a conditional ro To connect with your cloud provider, see the following tutorials: - [Configure OpenID Connect in AWS](aws/index.md) +- [Configure OpenID Connect in Azure](azure/index.md) - [Configure OpenID Connect in Google Cloud](google_cloud/index.md) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index df976584108..b4a234dc25a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -26857,6 +26857,9 @@ msgstr "" msgid "Notify|%{member_link} requested %{member_role} access to the %{target_source_link} %{target_type}." msgstr "" +msgid "Notify|%{mr_highlight}Merge request%{highlight_end} %{mr_link} %{approved_highlight}was approved by%{highlight_end} %{approver_avatar} %{approver_link}" +msgstr "" + msgid "Notify|Assignee changed from %{fromNames} to %{toNames}" msgstr "" @@ -26902,6 +26905,12 @@ msgstr "" msgid "Notify|Merge request URL: %{merge_request_url}" msgstr "" +msgid "Notify|Merge request was approved" +msgstr "" + +msgid "Notify|Merge request was approved (%{approvals}/%{required_approvals})" +msgstr "" + msgid "Notify|Milestone changed to %{milestone}" msgstr "" diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/sidebar_move_issue_spec.js index 7bb7b18adf8..2e6807ed9d8 100644 --- a/spec/frontend/sidebar/sidebar_move_issue_spec.js +++ b/spec/frontend/sidebar/sidebar_move_issue_spec.js @@ -7,6 +7,7 @@ import SidebarMoveIssue from '~/sidebar/lib/sidebar_move_issue'; import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; +import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown'; import Mock from './mock_data'; jest.mock('~/flash'); @@ -75,7 +76,9 @@ describe('SidebarMoveIssue', () => { it('should initialize the deprecatedJQueryDropdown', () => { test.sidebarMoveIssue.initDropdown(); - expect(test.sidebarMoveIssue.$dropdownToggle.data('deprecatedJQueryDropdown')).toBeTruthy(); + expect(test.sidebarMoveIssue.$dropdownToggle.data('deprecatedJQueryDropdown')).toBeInstanceOf( + GitLabDropdown, + ); }); it('escapes html from project name', async () => { @@ -97,7 +100,7 @@ describe('SidebarMoveIssue', () => { test.sidebarMoveIssue.onConfirmClicked(); expect(test.mediator.moveIssue).toHaveBeenCalled(); - expect(test.$confirmButton.prop('disabled')).toBeTruthy(); + expect(test.$confirmButton.prop('disabled')).toBe(true); expect(test.$confirmButton.hasClass('is-loading')).toBe(true); }); @@ -113,7 +116,7 @@ describe('SidebarMoveIssue', () => { await waitForPromises(); expect(createFlash).toHaveBeenCalled(); - expect(test.$confirmButton.prop('disabled')).toBeFalsy(); + expect(test.$confirmButton.prop('disabled')).toBe(false); expect(test.$confirmButton.hasClass('is-loading')).toBe(false); }); @@ -139,7 +142,7 @@ describe('SidebarMoveIssue', () => { test.$content.find('.js-move-issue-dropdown-item').eq(0).trigger('click'); expect(test.mediator.setMoveToProjectId).toHaveBeenCalledWith(0); - expect(test.$confirmButton.prop('disabled')).toBeTruthy(); + expect(test.$confirmButton.prop('disabled')).toBe(true); }); it('should set moveToProjectId on dropdown item click', async () => { diff --git a/spec/helpers/notify_helper_spec.rb b/spec/helpers/notify_helper_spec.rb index 654fb9bb3f8..77298d7e205 100644 --- a/spec/helpers/notify_helper_spec.rb +++ b/spec/helpers/notify_helper_spec.rb @@ -51,6 +51,33 @@ RSpec.describe NotifyHelper do end end + describe '#merge_request_approved_description' do + let(:merge_request) { create(:merge_request) } + let(:user) { create(:user) } + let(:avatar_icon_for_user) { 'avatar_icon_for_user' } + + before do + allow(helper).to receive(:avatar_icon_for_user).and_return(avatar_icon_for_user) + end + + it 'returns MR approved description' do + result = helper.merge_request_approved_description(merge_request, user) + expect(result).to eq "Merge request " \ + "#{ + link_to(merge_request.to_reference, merge_request_url(merge_request), + style: "font-weight: 600;color:#3777b0;text-decoration:none") + } " \ + "was approved by " \ + "#{ + content_tag(:img, nil, height: "24", src: avatar_icon_for_user, + style: "border-radius:12px;margin:-7px 0 -7px 3px;", + width: "24", alt: "Avatar", class: "avatar" + ) + } " \ + "#{link_to(user.name, user_url(user), style: "color:#333333;text-decoration:none;", class: "muted")}" + end + end + def reference_link(entity, url) "#{entity.to_reference}" end diff --git a/spec/views/notify/approved_merge_request_email.html.haml_spec.rb b/spec/views/notify/approved_merge_request_email.html.haml_spec.rb new file mode 100644 index 00000000000..7d19e628eb8 --- /dev/null +++ b/spec/views/notify/approved_merge_request_email.html.haml_spec.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'email_spec' + +RSpec.describe 'notify/approved_merge_request_email.html.haml' do + let(:user) { create(:user) } + let(:merge_request) { create(:merge_request) } + let(:group) { create(:group) } + let(:project) { create(:project, group: group) } + + before do + allow(view).to receive(:message) { instance_double(Mail::Message, subject: 'Subject') } + assign(:project, project) + assign(:approved_by, user) + assign(:merge_request, merge_request) + end + + it 'contains approval information' do + render + + expect(rendered).to have_content(merge_request.to_reference.to_s) + expect(rendered).to have_content("was approved by") + expect(rendered).to have_content(user.name.to_s) + end +end