diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 501c0aa6750daab528540befdcddddf326a548ed..8b94f5c7a4fb2146758218429187d79066a25a67 100644 --- a/app/mailers/emails/profile.rb +++ b/app/mailers/emails/profile.rb @@ -149,6 +149,19 @@ def access_token_revoked_email(user, token_name, source = nil) ) end + def access_token_rotated_email(user, token_name) + return unless user&.active? + + @user = user + @token_name = token_name + @target_url = user_settings_personal_access_tokens_url + + email_with_layout( + to: @user.notification_email_or_default, + subject: subject(_("Your personal access token has been rotated")) + ) + end + def deploy_token_about_to_expire_email(user, token_name, project, params = {}) params = params.with_indifferent_access diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index a7f41e2489a567ad5d2871dfb12f4df7d1b0ad89..4b63c615db848c4103377607cd2f676ef46756b3 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -123,6 +123,13 @@ def access_token_revoked(user, token_name, source = nil) mailer.access_token_revoked_email(user, token_name, source).deliver_later end + # Notify the user when one of their personal access tokens is rotated + def access_token_rotated(user, token_name) + return unless user.can?(:receive_notifications) + + mailer.access_token_rotated_email(user, token_name).deliver_later + end + # Notify the owner of the deploy token, when it is about to expire def deploy_token_about_to_expire(user, token_name, project, params = {}) return unless user.can?(:receive_notifications) diff --git a/app/services/personal_access_tokens/rotate_service.rb b/app/services/personal_access_tokens/rotate_service.rb index 6f79544df6dbaf076106729295814fd87ee1d72a..ad48675cec25bacda3812213dc96f75cb29c9c93 100644 --- a/app/services/personal_access_tokens/rotate_service.rb +++ b/app/services/personal_access_tokens/rotate_service.rb @@ -32,6 +32,8 @@ def execute track_rotation_event end + NotificationService.new.access_token_rotated(token.user, token.name) if response.success? + response end diff --git a/app/views/notify/access_token_rotated_email.html.haml b/app/views/notify/access_token_rotated_email.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..b2a15df0d5015b4306a4db48ad2c35d840c0459f --- /dev/null +++ b/app/views/notify/access_token_rotated_email.html.haml @@ -0,0 +1,7 @@ +%p + = _('Hi %{username}!') % { username: sanitize_name(@user.name) } +%p + = html_escape(_('Your personal access token, named %{code_start}%{token_name}%{code_end}, has been rotated.')) % { code_start: ''.html_safe, token_name: @token_name, code_end: ''.html_safe } +%p + - pat_link_start = ''.html_safe % { url: @target_url } + = html_escape(_('You can view it in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: ''.html_safe } diff --git a/app/views/notify/access_token_rotated_email.text.erb b/app/views/notify/access_token_rotated_email.text.erb new file mode 100644 index 0000000000000000000000000000000000000000..bdace6a1d6a5e577039e6b6a53a97fb84e69258b --- /dev/null +++ b/app/views/notify/access_token_rotated_email.text.erb @@ -0,0 +1,5 @@ +<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> + +<%= _('Your personal access token, named %{token_name}, has been rotated.') % { token_name: @token_name } %> + +<%= _('You can view it in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %> diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md index 3c0fc9cdee9c8409b01bff5dbad9c32d5154decf..a0eb2df7a8becd49e592d9e615863e7fbd9e135f 100644 --- a/doc/user/profile/notifications.md +++ b/doc/user/profile/notifications.md @@ -204,6 +204,7 @@ Users are notified of the following events: | Personal access tokens have been created | User | Security email, always sent. | | Personal access tokens have expired | User | Security email, always sent. | | Personal access token has been revoked | User | Security email, always sent. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/98911) in GitLab 15.5. | +| Personal access token has been rotated | User | Security email, always sent. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/199360) in GitLab 18.3. | | Group access tokens expiring soon | Direct Group Owners | Security email, always sent. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/367705) in GitLab 16.4. | | Project access tokens expiring soon | Direct Project Owners and Maintainers | Security email, always sent. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/367706) in GitLab 16.4. | | Project access level changed | User | Sent when user project access level is changed. | diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 3bb7d361b99af8b2bb4185f39a29365a8e5a46bc..db9d72acb4d682758d553f30f4836c208286a181 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -72986,6 +72986,12 @@ msgstr "" msgid "You can unsubscribe from further updates to this ticket. To receive updates in the future, you will have to be added again as a participant." msgstr "" +msgid "You can view it in your %{pat_link_start}personal access tokens%{pat_link_end} settings." +msgstr "" + +msgid "You can view it in your personal access tokens settings %{pat_link}." +msgstr "" + msgid "You can view the source or %{linkStart}%{cloneIcon} clone the repository%{linkEnd}" msgstr "" @@ -73857,6 +73863,15 @@ msgstr "" msgid "Your personal access token has been revoked" msgstr "" +msgid "Your personal access token has been rotated" +msgstr "" + +msgid "Your personal access token, named %{code_start}%{token_name}%{code_end}, has been rotated." +msgstr "" + +msgid "Your personal access token, named %{token_name}, has been rotated." +msgstr "" + msgid "Your personal access tokens have expired" msgstr "" diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 2e74e8cd1861a951a40e8cd27deab4c38ec17d0b..1cb61a1b7c080a513422f60d0327573271de00b2 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -426,6 +426,57 @@ end end + describe 'user personal access token has been rotated' do + let_it_be(:user) { create(:user) } + let_it_be(:token) { create(:personal_access_token, user: user) } + + context 'when valid' do + subject { Notify.access_token_rotated_email(user, token.name) } + + it_behaves_like 'an email sent from GitLab' + it_behaves_like 'it should not have Gmail Actions links' + it_behaves_like 'a user cannot unsubscribe through footer link' + + it 'is sent to the user' do + is_expected.to deliver_to user.email + end + + it 'has the correct subject' do + is_expected.to have_subject(/^Your personal access token has been rotated$/i) + end + + it 'provides the names of the token' do + is_expected.to have_body_text(/#{token.name}/) + end + + it 'includes a link to personal access tokens page' do + is_expected.to have_body_text(/#{user_settings_personal_access_tokens_path}/) + end + + it 'includes the email reason' do + is_expected.to have_body_text %r{You're receiving this email because of your account on localhost} + end + end + + context 'when invalid' do + context 'when user does not exist' do + it do + expect { Notify.access_token_rotated_email(nil, token.name) }.not_to change { ActionMailer::Base.deliveries.count } + end + end + + context 'when user is not active' do + before do + user.block! + end + + it do + expect { Notify.access_token_rotated_email(user, token.name) }.not_to change { ActionMailer::Base.deliveries.count } + end + end + end + end + describe 'SSH key notification' do let_it_be_with_reload(:user) { create(:user) } let_it_be(:fingerprints) { ["aa:bb:cc:dd:ee:zz"] } diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 9dc4150513b3db2082eb90bd2e380cb4634e5458..49a033aa92264cc56d0aab9f7c069eee9bef71e4 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -906,6 +906,27 @@ end end end + + describe '#access_token_rotated' do + let_it_be(:user) { create(:user) } + let_it_be(:pat) { create(:personal_access_token, user: user) } + + subject(:notification_service) { notification.access_token_rotated(user, pat.name) } + + it 'sends email to the token owner' do + expect { notification_service }.to have_enqueued_email(user, pat.name, mail: "access_token_rotated_email") + end + + context 'when user is not allowed to receive notifications' do + before do + user.block! + end + + it 'does not send email to the token owner' do + expect { notification_service }.not_to have_enqueued_email(user, pat.name, mail: "access_token_rotated_email") + end + end + end end describe 'SSH Keys' do diff --git a/spec/services/personal_access_tokens/rotate_service_spec.rb b/spec/services/personal_access_tokens/rotate_service_spec.rb index adfec2a67833cbebfa431502551d46159577f813..d6684c98fdd664120c3dcae62b984d9fd637823f 100644 --- a/spec/services/personal_access_tokens/rotate_service_spec.rb +++ b/spec/services/personal_access_tokens/rotate_service_spec.rb @@ -26,6 +26,14 @@ expect(new_token.organization).to eq(token.organization) expect(new_token.description).to eq(token.description) end + + it 'notifies the user' do + expect_next_instance_of(NotificationService) do |notification_service| + expect(notification_service).to receive(:access_token_rotated).with(token.user, token.name) + end + + response + end end it_behaves_like "rotates token successfully"