diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index f2fbb5b989e15e1dff5556f91b996aa1d8dd8f5b..6505741f798d59c66469311ff47849ddab453765 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -42,6 +42,7 @@ class PersonalAccessToken < ApplicationRecord scope :owner_is_human, -> { includes(:user).references(:user).merge(User.human) } scope :last_used_before, -> (date) { where("last_used_at <= ?", date) } scope :last_used_after, -> (date) { where("last_used_at >= ?", date) } + scope :expiring_and_not_notified_without_impersonation, -> { where(["(revoked = false AND expire_notification_delivered = false AND expires_at >= CURRENT_DATE AND expires_at <= :date) and impersonation = false", { date: DAYS_TO_EXPIRE.days.from_now.to_date }]) } validates :scopes, presence: true validates :expires_at, presence: true, on: :create, unless: :allow_expires_at_to_be_empty? diff --git a/app/models/user.rb b/app/models/user.rb index 409b286cfbb788d4234206af0cd39bdf7c7d4478..b5fefbe3cb8008d17c35aea2c118ab9d3ac1a639 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -168,6 +168,8 @@ def update_tracked_fields!(request) has_many :emails has_many :personal_access_tokens, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + has_many :expiring_soon_and_unnotified_personal_access_tokens, -> { expiring_and_not_notified_without_impersonation }, class_name: 'PersonalAccessToken' + has_many :identities, dependent: :destroy, autosave: true # rubocop:disable Cop/ActiveRecordDependent has_many :webauthn_registrations has_many :chat_names, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent @@ -615,6 +617,12 @@ def blocked? .where('keys.user_id = users.id') .expiring_soon_and_not_notified) end + + scope :with_personal_access_tokens_expiring_soon_and_ids, ->(ids) do + where(id: ids) + .includes(:expiring_soon_and_unnotified_personal_access_tokens) + end + scope :order_recent_sign_in, -> { reorder(arel_table[:current_sign_in_at].desc.nulls_last) } scope :order_oldest_sign_in, -> { reorder(arel_table[:current_sign_in_at].asc.nulls_last) } scope :order_recent_last_activity, -> { reorder(arel_table[:last_activity_on].desc.nulls_last, arel_table[:id].asc) } diff --git a/app/workers/personal_access_tokens/expiring_worker.rb b/app/workers/personal_access_tokens/expiring_worker.rb index 9a52a64bde1e67f0328b3aa80bb11a8d3ee81ae3..fbe65aa36089a75631e4ca5cfcb97f8ad906d28a 100644 --- a/app/workers/personal_access_tokens/expiring_worker.rb +++ b/app/workers/personal_access_tokens/expiring_worker.rb @@ -18,21 +18,18 @@ class ExpiringWorker # rubocop:disable Scalability/IdempotentWorker BATCH_SIZE = 100 def perform(*args) - limit_date = PersonalAccessToken::DAYS_TO_EXPIRE.days.from_now.to_date - # rubocop: disable CodeReuse/ActiveRecord -- We need to specify batch size to avoid timing out of worker loop do - tokens = PersonalAccessToken.without_impersonation.expiring_and_not_notified(limit_date) + tokens = PersonalAccessToken.expiring_and_not_notified_without_impersonation .select(:user_id).limit(BATCH_SIZE).to_a break if tokens.empty? - users = User.where(id: tokens.pluck(:user_id).uniq) + users = User.with_personal_access_tokens_expiring_soon_and_ids(tokens.pluck(:user_id).uniq) users.each do |user| with_context(user: user) do - expiring_user_tokens = user.personal_access_tokens - .without_impersonation.expiring_and_not_notified(limit_date) + expiring_user_tokens = user.expiring_soon_and_unnotified_personal_access_tokens next if expiring_user_tokens.empty? diff --git a/spec/models/personal_access_token_spec.rb b/spec/models/personal_access_token_spec.rb index 7665f4dbde477ed6b61e03d925d1496a6fc8c6aa..384bf40b0be85826b5c51e6a054c4b5fe2ab0158 100644 --- a/spec/models/personal_access_token_spec.rb +++ b/spec/models/personal_access_token_spec.rb @@ -327,6 +327,28 @@ end end + describe '.expiring_and_not_notified_without_impersonation' do + let_it_be(:expired_token) { create(:personal_access_token, expires_at: 2.days.ago) } + let_it_be(:revoked_token) { create(:personal_access_token, revoked: true) } + let_it_be(:valid_token_and_notified) { create(:personal_access_token, expires_at: 2.days.from_now, expire_notification_delivered: true) } + let_it_be(:valid_token) { create(:personal_access_token, expires_at: 2.days.from_now, impersonation: false) } + let_it_be(:long_expiry_token) { create(:personal_access_token, expires_at: described_class::MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS.days.from_now) } + + context 'when token is there to be notified' do + it "has only unnotified tokens" do + expect(described_class.expiring_and_not_notified_without_impersonation).to contain_exactly(valid_token) + end + end + + context 'when no token is there to be notified' do + it "return empty array" do + valid_token.update!(impersonation: true) + + expect(described_class.expiring_and_not_notified_without_impersonation).to be_empty + end + end + end + describe '.expired_today_and_not_notified' do let_it_be(:active) { create(:personal_access_token) } let_it_be(:expired_yesterday) { create(:personal_access_token, expires_at: Date.yesterday) } diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 607d18de48026740769549206607c3bbf0f7f7ec..1ca6ba92c197f6ba76fb28ec570e5363ada252df 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -162,6 +162,7 @@ it { is_expected.to have_many(:groups) } it { is_expected.to have_many(:keys).dependent(:destroy) } it { is_expected.to have_many(:expired_today_and_unnotified_keys) } + it { is_expected.to have_many(:expiring_soon_and_unnotified_personal_access_tokens) } it { is_expected.to have_many(:deploy_keys).dependent(:nullify) } it { is_expected.to have_many(:group_deploy_keys) } it { is_expected.to have_many(:events).dependent(:delete_all) } @@ -1413,6 +1414,24 @@ end end + describe '.with_personal_access_tokens_expiring_soon_and_ids' do + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + let_it_be(:pat1) { create(:personal_access_token, user: user1, expires_at: 2.days.from_now) } + let_it_be(:pat2) { create(:personal_access_token, user: user2, expires_at: 7.days.from_now) } + let_it_be(:ids) { [user1.id] } + + subject(:users) { described_class.with_personal_access_tokens_expiring_soon_and_ids(ids) } + + it 'filters users only by id' do + expect(users).to contain_exactly(user1) + end + + it 'includes expiring personal access tokens' do + expect(users.first.expiring_soon_and_unnotified_personal_access_tokens).to be_loaded + end + end + describe '.active_without_ghosts' do let_it_be(:user1) { create(:user, :external) } let_it_be(:user2) { create(:user, state: 'blocked') } diff --git a/spec/workers/personal_access_tokens/expiring_worker_spec.rb b/spec/workers/personal_access_tokens/expiring_worker_spec.rb index 3879d55668e0743692b1d390024c764954eddcd6..11dbdd1b301549f2a88205bb661f055e96706ab1 100644 --- a/spec/workers/personal_access_tokens/expiring_worker_spec.rb +++ b/spec/workers/personal_access_tokens/expiring_worker_spec.rb @@ -36,7 +36,8 @@ create(:personal_access_token, user: user2, expires_at: 5.days.from_now) # Query count increased for the user look up - expect { worker.perform }.not_to exceed_all_query_limit(control).with_threshold(4) + # there are still 2 N+1 queries one for token name look up and another for token update. + expect { worker.perform }.not_to exceed_all_query_limit(control).with_threshold(2) end end