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"