From fb6055bd68f9f1b48883bf84af2826140e57f465 Mon Sep 17 00:00:00 2001 From: Stephane Talbot Date: Sat, 26 Jul 2025 15:20:01 +0200 Subject: [PATCH 1/8] Send email notification when PAT is rotated Changelog: added --- app/mailers/emails/profile.rb | 13 ++++++++ app/services/notification_service.rb | 7 ++++ .../personal_access_tokens/rotate_service.rb | 2 ++ .../access_token_rotated_email.html.haml | 7 ++++ .../access_token_rotated_email.text.erb | 5 +++ doc/user/profile/notifications.md | 1 + locale/gitlab.pot | 9 +++++ spec/mailers/emails/profile_spec.rb | 33 +++++++++++++++++++ spec/services/notification_service_spec.rb | 21 ++++++++++++ .../rotate_service_spec.rb | 8 +++++ 10 files changed, 106 insertions(+) create mode 100644 app/views/notify/access_token_rotated_email.html.haml create mode 100644 app/views/notify/access_token_rotated_email.text.erb diff --git a/app/mailers/emails/profile.rb b/app/mailers/emails/profile.rb index 501c0aa6750daa..8b94f5c7a4fb21 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 a7f41e2489a567..4b63c615db848c 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 6f79544df6dbaf..ad48675cec25ba 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 00000000000000..6621aa09113386 --- /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(_('A 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 check 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 00000000000000..eed57c426f310f --- /dev/null +++ b/app/views/notify/access_token_rotated_email.text.erb @@ -0,0 +1,5 @@ +<%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> + +<%= _('A personal access token, named %{token_name}, has been rotated.') % { token_name: @token_name } %> + +<%= _('You can check it in your 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 3c0fc9cdee9c84..e29af16cbf9976 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. | | 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 3bb7d361b99af8..43758c7fd3b141 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2266,9 +2266,15 @@ msgstr "" msgid "A personal access token, named %{code_start}%{token_name}%{code_end}, has been revoked." msgstr "" +msgid "A personal access token, named %{code_start}%{token_name}%{code_end}, has been rotated." +msgstr "" + msgid "A personal access token, named %{token_name}, has been revoked." msgstr "" +msgid "A personal access token, named %{token_name}, has been rotated." +msgstr "" + msgid "A project boilerplate for Salesforce App development with Salesforce Developer tools" msgstr "" @@ -73857,6 +73863,9 @@ msgstr "" msgid "Your personal access token has been revoked" msgstr "" +msgid "Your personal access token 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 2e74e8cd1861a9..2a97b7d2f0cf7d 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -426,6 +426,39 @@ 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 + 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 9dc4150513b3db..49a033aa92264c 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 adfec2a67833cb..d6684c98fdd664 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" -- GitLab From 9103013c283f5c20829eb0c35a1ee7ea3f7c3e30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Talbot?= Date: Mon, 28 Jul 2025 12:35:14 +0000 Subject: [PATCH 2/8] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: GitLab Duo --- app/views/notify/access_token_rotated_email.text.erb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/notify/access_token_rotated_email.text.erb b/app/views/notify/access_token_rotated_email.text.erb index eed57c426f310f..11489edfe1c888 100644 --- a/app/views/notify/access_token_rotated_email.text.erb +++ b/app/views/notify/access_token_rotated_email.text.erb @@ -2,4 +2,4 @@ <%= _('A personal access token, named %{token_name}, has been rotated.') % { token_name: @token_name } %> -<%= _('You can check it in your in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %> +<%= _('You can check it in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %> -- GitLab From 93138b1d0aaa1581c7221e04a3d7e55ee65af625 Mon Sep 17 00:00:00 2001 From: Stephane Talbot Date: Mon, 28 Jul 2025 14:36:58 +0200 Subject: [PATCH 3/8] Update gitlab.pot file --- locale/gitlab.pot | 3 +++ 1 file changed, 3 insertions(+) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 43758c7fd3b141..9a3b10de5c11be 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -72881,6 +72881,9 @@ msgstr "" msgid "You can check it in your personal access tokens settings %{pat_link}." msgstr "" +msgid "You can check it in your personal access tokens settings %{pat_link}." +msgstr "" + msgid "You can check your tokens or create a new one in your %{pat_link_start}personal access tokens settings%{pat_link_end}." msgstr "" -- GitLab From ad685c9a14a96b943614dc87c1ff670abb7f221c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Talbot?= Date: Mon, 28 Jul 2025 13:57:58 +0000 Subject: [PATCH 4/8] Apply 1 suggestion(s) to 1 file(s) Co-authored-by: Diana Zubova --- doc/user/profile/notifications.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/user/profile/notifications.md b/doc/user/profile/notifications.md index e29af16cbf9976..a0eb2df7a8becd 100644 --- a/doc/user/profile/notifications.md +++ b/doc/user/profile/notifications.md @@ -204,7 +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. | +| 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. | -- GitLab From 69c97a26a93134edd54857304aa8407014e17f9f Mon Sep 17 00:00:00 2001 From: Stephane Talbot Date: Wed, 30 Jul 2025 17:07:14 +0200 Subject: [PATCH 5/8] Revert "Update gitlab.pot file" Revert commit 5e237bb1cb277f49ce7a623cfb9ed7a02972586bc because gitlab-org/gitlab!199420 has been merged. --- locale/gitlab.pot | 3 --- 1 file changed, 3 deletions(-) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 9a3b10de5c11be..43758c7fd3b141 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -72881,9 +72881,6 @@ msgstr "" msgid "You can check it in your personal access tokens settings %{pat_link}." msgstr "" -msgid "You can check it in your personal access tokens settings %{pat_link}." -msgstr "" - msgid "You can check your tokens or create a new one in your %{pat_link_start}personal access tokens settings%{pat_link_end}." msgstr "" -- GitLab From 2e3bfe521be8963f0b86e9f60bb5c67c98790077 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Talbot?= Date: Thu, 31 Jul 2025 16:30:08 +0000 Subject: [PATCH 6/8] Apply 4 suggestion(s) to 2 file(s) Co-authored-by: Kati Paizee --- app/views/notify/access_token_rotated_email.html.haml | 4 ++-- app/views/notify/access_token_rotated_email.text.erb | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/views/notify/access_token_rotated_email.html.haml b/app/views/notify/access_token_rotated_email.html.haml index 6621aa09113386..b2a15df0d5015b 100644 --- a/app/views/notify/access_token_rotated_email.html.haml +++ b/app/views/notify/access_token_rotated_email.html.haml @@ -1,7 +1,7 @@ %p = _('Hi %{username}!') % { username: sanitize_name(@user.name) } %p - = html_escape(_('A 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 } + = 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 check it in your %{pat_link_start}personal access tokens%{pat_link_end} settings.')) % { pat_link_start: pat_link_start, pat_link_end: ''.html_safe } + = 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 index 11489edfe1c888..bdace6a1d6a5e5 100644 --- a/app/views/notify/access_token_rotated_email.text.erb +++ b/app/views/notify/access_token_rotated_email.text.erb @@ -1,5 +1,5 @@ <%= _('Hi %{username}!') % { username: sanitize_name(@user.name) } %> -<%= _('A personal access token, named %{token_name}, has been rotated.') % { token_name: @token_name } %> +<%= _('Your personal access token, named %{token_name}, has been rotated.') % { token_name: @token_name } %> -<%= _('You can check it in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %> +<%= _('You can view it in your personal access tokens settings %{pat_link}.') % { pat_link: @target_url } %> -- GitLab From 091ed9e6cc94db805e0e285d122313035854606f Mon Sep 17 00:00:00 2001 From: Stephane Talbot Date: Thu, 31 Jul 2025 16:58:24 +0200 Subject: [PATCH 7/8] Update gitlab.pot --- locale/gitlab.pot | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 43758c7fd3b141..db9d72acb4d682 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -2266,15 +2266,9 @@ msgstr "" msgid "A personal access token, named %{code_start}%{token_name}%{code_end}, has been revoked." msgstr "" -msgid "A personal access token, named %{code_start}%{token_name}%{code_end}, has been rotated." -msgstr "" - msgid "A personal access token, named %{token_name}, has been revoked." msgstr "" -msgid "A personal access token, named %{token_name}, has been rotated." -msgstr "" - msgid "A project boilerplate for Salesforce App development with Salesforce Developer tools" msgstr "" @@ -72992,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 "" @@ -73866,6 +73866,12 @@ 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 "" -- GitLab From b659c597a716a585c1cb6614a566a539ab50f4f2 Mon Sep 17 00:00:00 2001 From: Stephane Talbot Date: Thu, 31 Jul 2025 14:57:15 +0200 Subject: [PATCH 8/8] Verify notification is not sent when user is invalid --- spec/mailers/emails/profile_spec.rb | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/spec/mailers/emails/profile_spec.rb b/spec/mailers/emails/profile_spec.rb index 2a97b7d2f0cf7d..1cb61a1b7c080a 100644 --- a/spec/mailers/emails/profile_spec.rb +++ b/spec/mailers/emails/profile_spec.rb @@ -457,6 +457,24 @@ 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 -- GitLab