1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178
|
# frozen_string_literal: true
class PersonalAccessToken < ApplicationRecord
include Expirable
include TokenAuthenticatable
include Sortable
include EachBatch
include CreatedAtFilterable
include Gitlab::SQL::Pattern
include SafelyChangeColumnDefault
extend ::Gitlab::Utils::Override
NOTIFICATION_INTERVALS = {
seven_days: 0..7,
thirty_days: 8..30,
sixty_days: 31..60
}.freeze
add_authentication_token_field :token,
digest: true,
format_with_prefix: :prefix_from_application_current_settings
columns_changing_default :organization_id
attribute :organization_id, default: -> { Organizations::Organization::DEFAULT_ORGANIZATION_ID }
# PATs are 20 characters + optional configurable settings prefix (0..20)
TOKEN_LENGTH_RANGE = (20..40)
MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS_BUFFERED = 400
MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS = 365
serialize :scopes, Array # rubocop:disable Cop/ActiveRecordSerialize
belongs_to :user
belongs_to :organization, class_name: 'Organizations::Organization'
belongs_to :previous_personal_access_token, class_name: 'PersonalAccessToken'
after_initialize :set_default_scopes, if: :persisted?
before_save :ensure_token
scope :active, -> { not_revoked.not_expired }
# this scope must use a string condition, otherwise Postgres will not use the correct indices
scope :expiring_and_not_notified, ->(date) { where(["revoked = false AND expire_notification_delivered = false AND seven_days_notification_sent_at IS NULL AND expires_at >= CURRENT_DATE AND expires_at <= ?", date]) }
scope :expired_today_and_not_notified, -> { where(["revoked = false AND expires_at = CURRENT_DATE AND after_expiry_notification_delivered = false"]) }
scope :expired_before, ->(date) { expired.where(arel_table[:expires_at].lt(date)) }
scope :inactive, -> { where("revoked = true OR expires_at < CURRENT_DATE") }
scope :last_used_before_or_unused, ->(date) { where("personal_access_tokens.created_at < :date AND (last_used_at < :date OR last_used_at IS NULL)", date: date) }
scope :with_impersonation, -> { where(impersonation: true) }
scope :without_impersonation, -> { where(impersonation: false) }
scope :revoked, -> { where(revoked: true) }
scope :revoked_before, ->(date) { revoked.where(arel_table[:updated_at].lt(date)) }
scope :not_revoked, -> { where(revoked: [false, nil]) }
scope :for_user, ->(user) { where(user: user) }
scope :for_users, ->(users) { where(user: users) }
scope :for_organization, ->(organization) { where(organization_id: organization) }
scope :preload_users, -> { preload(:user) }
scope :order_expires_at_asc_id_desc, -> { reorder(expires_at: :asc, id: :desc) }
scope :project_access_token, -> { includes(:user).references(:user).merge(User.project_bot) }
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, -> {
expiring_and_not_notified(DAYS_TO_EXPIRE.days.from_now.to_date).without_impersonation
}
validates :name, :scopes, presence: true
validates :expires_at, presence: true, on: :create, unless: :allow_expires_at_to_be_empty?
validate :validate_scopes
validate :expires_at_before_instance_max_expiry_date, on: :create
def revoke!
if persisted?
update_columns(revoked: true, updated_at: Time.zone.now)
else
self.revoked = true
end
end
def active?
!revoked? && !expired?
end
override :simple_sorts
def self.simple_sorts
super.merge(
{
'expires_at_asc_id_desc' => -> { order_expires_at_asc_id_desc }
}
)
end
def self.token_prefix
Gitlab::CurrentSettings.current_application_settings.personal_access_token_prefix
end
def self.search(query)
fuzzy_search(query, [:name])
end
def self.notification_interval(interval)
NOTIFICATION_INTERVALS.fetch(interval).max
end
def self.scope_for_notification_interval(interval, min_expires_at: nil, max_expires_at: nil)
interval_range = NOTIFICATION_INTERVALS.fetch(interval).minmax
min_expiry_date, max_expiry_date = interval_range.map { |range| Date.current + range }
min_expiry_date = min_expires_at if min_expires_at
max_expiry_date = max_expires_at if max_expires_at
interval_attr = "#{interval}_notification_sent_at"
sql_string = <<~SQL
revoked = FALSE
AND #{interval_attr} IS NULL
AND expire_notification_delivered = FALSE
AND expires_at BETWEEN ? AND ?
SQL
# this scope must use a string condition rather than activerecord syntax,
# otherwise Postgres will not use the correct indices
where(sql_string, min_expiry_date, max_expiry_date).without_impersonation
end
def hook_attrs
Gitlab::HookData::ResourceAccessTokenBuilder.new(self).build
end
protected
def validate_scopes
unless revoked || scopes.all? { |scope| Gitlab::Auth.all_available_scopes.include?(scope.to_sym) }
errors.add :scopes, "can only contain available scopes"
end
end
def set_default_scopes
# When only loading a select set of attributes, for example using `EachBatch`,
# the `scopes` attribute is not present, so we can't initialize it.
return unless has_attribute?(:scopes)
self.scopes = Gitlab::Auth::DEFAULT_SCOPES if self.scopes.empty?
end
def user_admin?
user.admin? # rubocop: disable Cop/UserAdmin
end
def prefix_from_application_current_settings
self.class.token_prefix
end
def allow_expires_at_to_be_empty?
!Gitlab::CurrentSettings.require_personal_access_token_expiry?
end
def max_expiration_lifetime_in_days
if ::Feature.enabled?(:buffered_token_expiration_limit) # rubocop:disable Gitlab/FeatureFlagWithoutActor -- Group setting but checked at user
MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS_BUFFERED
else
MAX_PERSONAL_ACCESS_TOKEN_LIFETIME_IN_DAYS
end
end
def expires_at_before_instance_max_expiry_date
return unless expires_at
max_expiry_date = Date.current.advance(days: max_expiration_lifetime_in_days)
return unless expires_at > max_expiry_date
errors.add(
:expires_at,
format(_("must be before %{expiry_date}"), expiry_date: max_expiry_date)
)
end
end
PersonalAccessToken.prepend_mod_with('PersonalAccessToken')
|