Commit 84579101 authored by Aishwarya Subramanian's avatar Aishwarya Subramanian

In app notification for PAT expiration

Shows an in-app notification when a PAT
is about to expire (in the next 7 days)
or has already expired.
The feature is applicable only when
enformcement of PAT expiry is disabled.
(ref: https://gitlab.com/gitlab-org/gitlab/-/issues/214723)
parent 9937adfe
...@@ -6,6 +6,7 @@ const PERSISTENT_USER_CALLOUTS = [ ...@@ -6,6 +6,7 @@ const PERSISTENT_USER_CALLOUTS = [
'.js-admin-licensed-user-count-threshold', '.js-admin-licensed-user-count-threshold',
'.js-buy-pipeline-minutes-notification-callout', '.js-buy-pipeline-minutes-notification-callout',
'.js-alerts-moved-alert', '.js-alerts-moved-alert',
'.js-token-expiry-callout',
]; ];
const initCallouts = () => { const initCallouts = () => {
......
...@@ -18,7 +18,8 @@ module UserCalloutEnums ...@@ -18,7 +18,8 @@ module UserCalloutEnums
tabs_position_highlight: 10, tabs_position_highlight: 10,
webhooks_moved: 13, webhooks_moved: 13,
admin_integrations_moved: 15, admin_integrations_moved: 15,
alerts_moved: 20 alerts_moved: 20,
personal_access_token_expiry: 21 # EE-only
} }
end end
end end
......
...@@ -7,6 +7,7 @@ ...@@ -7,6 +7,7 @@
= render 'shared/outdated_browser' = render 'shared/outdated_browser'
= render_if_exists 'layouts/header/users_over_license_banner' = render_if_exists 'layouts/header/users_over_license_banner'
= render_if_exists "layouts/header/licensed_user_count_threshold" = render_if_exists "layouts/header/licensed_user_count_threshold"
= render_if_exists "layouts/header/token_expiry_notification"
= render "layouts/broadcast" = render "layouts/broadcast"
= render "layouts/header/read_only_banner" = render "layouts/header/read_only_banner"
= render "layouts/nav/classification_level_banner" = render "layouts/nav/classification_level_banner"
......
...@@ -14,6 +14,7 @@ module EE ...@@ -14,6 +14,7 @@ module EE
USERS_OVER_LICENSE_BANNER = 'users_over_license_banner' USERS_OVER_LICENSE_BANNER = 'users_over_license_banner'
STANDALONE_VULNERABILITIES_INTRODUCTION_BANNER = 'standalone_vulnerabilities_introduction_banner' STANDALONE_VULNERABILITIES_INTRODUCTION_BANNER = 'standalone_vulnerabilities_introduction_banner'
ACTIVE_USER_COUNT_THRESHOLD = 'active_user_count_threshold' ACTIVE_USER_COUNT_THRESHOLD = 'active_user_count_threshold'
PERSONAL_ACCESS_TOKEN_EXPIRY = 'personal_access_token_expiry'
def show_canary_deployment_callout?(project) def show_canary_deployment_callout?(project)
!user_dismissed?(CANARY_DEPLOYMENT) && !user_dismissed?(CANARY_DEPLOYMENT) &&
...@@ -87,6 +88,12 @@ module EE ...@@ -87,6 +88,12 @@ module EE
!user_dismissed?(STANDALONE_VULNERABILITIES_INTRODUCTION_BANNER) !user_dismissed?(STANDALONE_VULNERABILITIES_INTRODUCTION_BANNER)
end end
def show_token_expiry_notification?
!token_expiration_enforced? &&
current_user.active? &&
!user_dismissed?(PERSONAL_ACCESS_TOKEN_EXPIRY, 1.week.ago)
end
private private
def hashed_storage_enabled? def hashed_storage_enabled?
...@@ -126,5 +133,9 @@ module EE ...@@ -126,5 +133,9 @@ module EE
def show_gold_trial_suitable_env? def show_gold_trial_suitable_env?
::Gitlab.com? && !::Gitlab::Database.read_only? ::Gitlab.com? && !::Gitlab::Database.read_only?
end end
def token_expiration_enforced?
::PersonalAccessToken.expiration_enforced?
end
end end
end end
...@@ -23,6 +23,14 @@ module PersonalAccessTokensHelper ...@@ -23,6 +23,14 @@ module PersonalAccessTokensHelper
PersonalAccessToken.enforce_pat_expiration_feature_available? PersonalAccessToken.enforce_pat_expiration_feature_available?
end end
def token_expiry_banner_message(user)
verifier = PersonalAccessTokens::RotationVerifierService.new(user)
return _('At least one of your Personal Access Tokens is expired, but expiration enforcement is disabled. %{generate_new}') if verifier.expired?
return _('At least one of your Personal Access Tokens will expire soon, but expiration enforcement is disabled. %{generate_new}') if verifier.expiring_soon?
end
private private
def instance_level_personal_access_token_expiration_policy_enabled? def instance_level_personal_access_token_expiration_policy_enabled?
......
...@@ -13,8 +13,12 @@ module EE ...@@ -13,8 +13,12 @@ module EE
include ::Gitlab::Utils::StrongMemoize include ::Gitlab::Utils::StrongMemoize
include FromUnion include FromUnion
after_create :clear_rotation_notification_cache
scope :with_no_expires_at, -> { where(revoked: false, expires_at: nil) } scope :with_no_expires_at, -> { where(revoked: false, expires_at: nil) }
scope :with_expires_at_after, ->(max_lifetime) { where(revoked: false).where('expires_at > ?', max_lifetime) } scope :with_expires_at_after, ->(max_lifetime) { where(revoked: false).where('expires_at > ?', max_lifetime) }
scope :expires_in, ->(within) { not_revoked.where('expires_at > NOW() AND expires_at <= ?', within) }
scope :created_on_or_after, ->(date) { active.where('created_at >= ?', date) }
with_options if: :expiration_policy_enabled? do with_options if: :expiration_policy_enabled? do
validates :expires_at, presence: true validates :expires_at, presence: true
...@@ -56,6 +60,13 @@ module EE ...@@ -56,6 +60,13 @@ module EE
false false
end end
override :revoke!
def revoke!
clear_rotation_notification_cache
super
end
private private
def expiration_policy_enabled? def expiration_policy_enabled?
...@@ -95,5 +106,9 @@ module EE ...@@ -95,5 +106,9 @@ module EE
def group_level_max_expiry_date def group_level_max_expiry_date
user.managing_group.max_personal_access_token_lifetime_from_now user.managing_group.max_personal_access_token_lifetime_from_now
end end
def clear_rotation_notification_cache
::PersonalAccessTokens::RotationVerifierService.new(user).clear_cache
end
end end
end end
# frozen_string_literal: true
module PersonalAccessTokens
class RotationVerifierService
def initialize(user)
@user = user
end
# If a new token has been created after we started notifying the user about the most recently EXPIRED token,
# rotation is NOT needed.
# For example: If the most recent token expired on 14th of June, and user created a token anytime on or after
# 7th of June (first notification date), no rotation is required.
def expired?
Rails.cache.fetch(expired_cache_key, expires_in: expires_in.minutes) do
most_recent_expires_at = tokens_without_impersonation.not_revoked.expired.maximum(:expires_at)
if most_recent_expires_at.nil?
false
else
!tokens_without_impersonation.created_on_or_after(most_recent_expires_at - Expirable::DAYS_TO_EXPIRE).exists?
end
end
end
# If a new token has been created after we started notifying the user about the most recently EXPIRING token,
# rotation is NOT needed.
# User is notified about an expiring token before `days_within` (7 days) of expiry
def expiring_soon?
Rails.cache.fetch(expiring_cache_key, expires_in: expires_in.minutes) do
most_recent_expires_at = tokens_without_impersonation.expires_in(Expirable::DAYS_TO_EXPIRE.days.from_now).maximum(:expires_at)
if most_recent_expires_at.nil?
false
else
!tokens_without_impersonation.created_on_or_after(most_recent_expires_at - Expirable::DAYS_TO_EXPIRE).exists?
end
end
end
def clear_cache
Rails.cache.delete(expired_cache_key)
Rails.cache.delete(expiring_cache_key)
end
private
attr_reader :user
NUMBER_OF_MINUTES = 60
def expired_cache_key
['users', user.id, 'token_expired_rotation']
end
def expiring_cache_key
['users', user.id, 'token_expiring_rotation']
end
def tokens_without_impersonation
@tokens_without_impersonation ||= user
.personal_access_tokens
.without_impersonation
end
# Expire the cache at the end of day
# Calculates the number of minutes remaining from now until end of day
def expires_in
(Time.current.at_end_of_day - Time.current) / NUMBER_OF_MINUTES
end
end
end
- return unless show_token_expiry_notification?
- message = token_expiry_banner_message(current_user)
- return unless message
- link = link_to _('Generate new token'), profile_personal_access_tokens_path
.gl-alert.gl-alert-danger.js-token-expiry-callout{ role: 'alert', data: { feature_id: "personal_access_token_expiry", dismiss_endpoint: user_callouts_path, defer_links: "true" } }
%button.js-close.gl-alert-dismiss.gl-cursor-pointer{ type: 'button', 'aria-label' => _('Dismiss') }
= sprite_icon('close', size: 16, css_class: 'gl-icon')
.gl-alert-body
= sprite_icon('warning', size: 16, css_class: 'vertical-align-text-top')
= message.html_safe % { generate_new: link }
---
title: Add in-app notification for Personal Access Token expiry
merge_request: 34101
author:
type: added
...@@ -3,6 +3,8 @@ ...@@ -3,6 +3,8 @@
require "spec_helper" require "spec_helper"
RSpec.describe EE::UserCalloutsHelper do RSpec.describe EE::UserCalloutsHelper do
using RSpec::Parameterized::TableSyntax
describe '.render_enable_hashed_storage_warning' do describe '.render_enable_hashed_storage_warning' do
context 'when we should show the enable warning' do context 'when we should show the enable warning' do
it 'renders the enable warning' do it 'renders the enable warning' do
...@@ -171,8 +173,6 @@ RSpec.describe EE::UserCalloutsHelper do ...@@ -171,8 +173,6 @@ RSpec.describe EE::UserCalloutsHelper do
end end
describe '#render_dashboard_gold_trial' do describe '#render_dashboard_gold_trial' do
using RSpec::Parameterized::TableSyntax
let_it_be(:namespace) { create(:namespace) } let_it_be(:namespace) { create(:namespace) }
let_it_be(:gold_plan) { create(:gold_plan) } let_it_be(:gold_plan) { create(:gold_plan) }
let(:user) { namespace.owner } let(:user) { namespace.owner }
...@@ -236,8 +236,6 @@ RSpec.describe EE::UserCalloutsHelper do ...@@ -236,8 +236,6 @@ RSpec.describe EE::UserCalloutsHelper do
end end
describe '#render_billings_gold_trial' do describe '#render_billings_gold_trial' do
using RSpec::Parameterized::TableSyntax
let(:namespace) { create(:namespace) } let(:namespace) { create(:namespace) }
let_it_be(:free_plan) { create(:free_plan) } let_it_be(:free_plan) { create(:free_plan) }
let_it_be(:silver_plan) { create(:silver_plan) } let_it_be(:silver_plan) { create(:silver_plan) }
...@@ -289,8 +287,6 @@ RSpec.describe EE::UserCalloutsHelper do ...@@ -289,8 +287,6 @@ RSpec.describe EE::UserCalloutsHelper do
end end
describe '#render_account_recovery_regular_check' do describe '#render_account_recovery_regular_check' do
using RSpec::Parameterized::TableSyntax
let(:new_user) { create(:user) } let(:new_user) { create(:user) }
let(:old_user) { create(:user, created_at: 4.months.ago )} let(:old_user) { create(:user, created_at: 4.months.ago )}
let(:anonymous) { nil } let(:anonymous) { nil }
...@@ -348,6 +344,36 @@ RSpec.describe EE::UserCalloutsHelper do ...@@ -348,6 +344,36 @@ RSpec.describe EE::UserCalloutsHelper do
end end
end end
describe '.show_token_expiry_notification?' do
subject { helper.show_token_expiry_notification? }
let_it_be(:user) { create(:user) }
where(:expiration_enforced?, :dismissed_callout?, :active?, :result) do
true | true | true | false
true | true | false | false
true | false | true | false
false | true | true | false
true | false | false | false
false | false | true | true
false | true | false | false
false | false | false | false
end
with_them do
before do
allow(helper).to receive(:current_user).and_return(user)
allow(user).to receive(:active?).and_return(active?)
allow(helper).to receive(:token_expiration_enforced?).and_return(expiration_enforced?)
allow(user).to receive(:dismissed_callout?).and_return(dismissed_callout?)
end
it do
expect(subject).to be result
end
end
end
describe '.show_standalone_vulnerabilities_introduction_banner?' do describe '.show_standalone_vulnerabilities_introduction_banner?' do
subject { helper.show_standalone_vulnerabilities_introduction_banner? } subject { helper.show_standalone_vulnerabilities_introduction_banner? }
......
...@@ -160,4 +160,22 @@ RSpec.describe PersonalAccessTokensHelper do ...@@ -160,4 +160,22 @@ RSpec.describe PersonalAccessTokensHelper do
it_behaves_like 'feature availability' it_behaves_like 'feature availability'
end end
describe '#token_expiry_banner_message' do
subject { helper.token_expiry_banner_message(user) }
let_it_be(:user) { create(:user) }
context 'when user has an expired token requiring rotation' do
let_it_be(:expired_pat) { create(:personal_access_token, :expired, user: user, created_at: 1.month.ago) }
it { is_expected.to eq('At least one of your Personal Access Tokens is expired, but expiration enforcement is disabled. %{generate_new}') }
end
context 'when user has an expiring token requiring rotation' do
let_it_be(:expiring_pat) { create(:personal_access_token, expires_at: 3.days.from_now, user: user, created_at: 1.month.ago) }
it { is_expected.to eq('At least one of your Personal Access Tokens will expire soon, but expiration enforcement is disabled. %{generate_new}') }
end
end
end end
...@@ -252,4 +252,39 @@ RSpec.describe PersonalAccessToken do ...@@ -252,4 +252,39 @@ RSpec.describe PersonalAccessToken do
it { expect(subject).to be result } it { expect(subject).to be result }
end end
end end
shared_context 'write to cache' do
let_it_be(:pat) { create(:personal_access_token) }
let_it_be(:cache_keys) { %w(token_expired_rotation token_expiring_rotation) }
before do
cache_keys.each do |key|
Rails.cache.write(['users', pat.user.id, key], double)
end
end
end
describe '#revoke', :use_clean_rails_memory_store_caching do
include_context 'write to cache'
it 'clears cache on revoke access' do
pat.revoke!
cache_keys.each do |key|
expect(Rails.cache.read(['users', pat.user.id, key])).to be_nil
end
end
end
describe 'after create callback', :use_clean_rails_memory_store_caching do
include_context 'write to cache'
it 'clears cache for the user' do
create(:personal_access_token, user_id: pat.user_id)
cache_keys.each do |key|
expect(Rails.cache.read(['users', pat.user.id, key])).to be_nil
end
end
end
end end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe PersonalAccessTokens::RotationVerifierService do
let_it_be(:user) { create(:user) }
let_it_be(:no_pat_user) { create(:user) }
let_it_be(:active_pat) { create(:personal_access_token, user: user, expires_at: 2.months.from_now, created_at: 1.month.ago) }
shared_examples 'rotation required' do
it { is_expected.to be true }
end
shared_examples 'rotation NOT required' do
it { is_expected.to be false }
end
shared_examples 'stores in cache' do
it do
subject
expect(Rails.cache.read(['users', user.id, key])).to eq(value)
end
end
describe '#expired?' do
subject { described_class.new(user).expired? }
let_it_be(:recent_expired_pat) { create(:personal_access_token, :expired, user: user, created_at: 1.month.ago) }
context 'when no new token was created after notification for expired token started' do
it_behaves_like 'rotation required'
context 'cache', :use_clean_rails_memory_store_caching do
let(:key) { 'token_expired_rotation' }
let(:value) { true }
it_behaves_like 'stores in cache'
end
end
context 'when token was created after notification for expired token started' do
before do
create(:personal_access_token, user: user, created_at: recent_expired_pat.expires_at + 1.day)
end
it_behaves_like 'rotation NOT required'
context 'cache', :use_clean_rails_memory_store_caching do
let(:key) { 'token_expired_rotation' }
let(:value) { false }
it_behaves_like 'stores in cache'
end
end
context 'with multiple expired tokens' do
let_it_be(:expired_pat1) { create(:personal_access_token, expires_at: 12.days.ago, user: user, created_at: 1.month.ago) }
context 'when no new token was created after notification for expired token started' do
it_behaves_like 'rotation required'
end
context 'when new token was created after notification for ONLY first expired token started' do
before do
create(:personal_access_token, user: user, created_at: expired_pat1.expires_at + 1.day)
end
it_behaves_like 'rotation required'
end
context 'when new token was created after notification for most recent expired token started' do
before do
create(:personal_access_token, user: user, created_at: recent_expired_pat.expires_at + 1.day)
end
it_behaves_like 'rotation NOT required'
end
end
context 'For user with no PATs' do
subject { described_class.new(no_pat_user).expired? }
it_behaves_like 'rotation NOT required'
end
end
describe '#expiring_soon?' do
subject { described_class.new(user).expiring_soon? }
let_it_be(:recent_expiring_pat) { create(:personal_access_token, user: user, expires_at: 6.days.from_now, created_at: 1.month.ago) }
context 'when no new token was created after notification for recent expiring token started' do
it_behaves_like 'rotation required'
context 'cache', :use_clean_rails_memory_store_caching do
let(:key) { 'token_expiring_rotation' }
let(:value) { true }
it_behaves_like 'stores in cache'
end
end
context 'when token was created after notification for recent expiring token started' do
before do
create(:personal_access_token, user: user, created_at: recent_expiring_pat.expires_at - 2.days)
end
it_behaves_like 'rotation NOT required'
context 'cache', :use_clean_rails_memory_store_caching do
let(:key) { 'token_expiring_rotation' }
let(:value) { false }
it_behaves_like 'stores in cache'
end
end
context 'with multiple expiring tokens' do
let_it_be(:expiring_pat1) { create(:personal_access_token, expires_at: 4.days.ago, user: user, created_at: 1.month.ago) }
context 'when no new token was created after notification for expiring token started' do
it_behaves_like 'rotation required'
end
context 'when new token was created after notification for ONLY first expiring token started' do
before do
create(:personal_access_token, user: user, created_at: expiring_pat1.expires_at - 1.day)
end
it_behaves_like 'rotation required'
end
context 'when new token was created after notification for most recent expiring token started' do
before do
create(:personal_access_token, user: user, created_at: recent_expiring_pat.expires_at - 1.day)
end
it_behaves_like 'rotation NOT required'
end
end
context 'For user with no PATs' do
subject { described_class.new(no_pat_user).expiring_soon? }
it_behaves_like 'rotation NOT required'
end
end
describe '#clear_cache', :use_clean_rails_memory_store_caching do
let_it_be(:cache_keys) { %w(token_expired_rotation token_expiring_rotation) }
before do
cache_keys.each do |key|
Rails.cache.write(['users', user.id, key], double)
end
end
it 'clears cache' do
described_class.new(user).clear_cache
cache_keys.each do |key|
expect(Rails.cache.read(['users', user.id, key])).to be_nil
end
end
end
end
...@@ -3154,6 +3154,12 @@ msgstr "" ...@@ -3154,6 +3154,12 @@ msgstr ""
msgid "At least one of group_id or project_id must be specified" msgid "At least one of group_id or project_id must be specified"
msgstr "" msgstr ""
msgid "At least one of your Personal Access Tokens is expired, but expiration enforcement is disabled. %{generate_new}"
msgstr ""
msgid "At least one of your Personal Access Tokens will expire soon, but expiration enforcement is disabled. %{generate_new}"
msgstr ""
msgid "At risk" msgid "At risk"
msgstr "" msgstr ""
...@@ -10348,6 +10354,9 @@ msgstr "" ...@@ -10348,6 +10354,9 @@ msgstr ""
msgid "Generate new export" msgid "Generate new export"
msgstr "" msgstr ""
msgid "Generate new token"
msgstr ""
msgid "GenericReports|Report" msgid "GenericReports|Report"
msgstr "" msgstr ""
......
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