rubygems/rubygems.org

View on GitHub
app/models/api_key.rb

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
class ApiKey < ApplicationRecord
  API_SCOPES = %i[show_dashboard index_rubygems push_rubygem yank_rubygem add_owner remove_owner access_webhooks
                  configure_trusted_publishers].freeze
  APPLICABLE_GEM_API_SCOPES = %i[push_rubygem yank_rubygem add_owner remove_owner configure_trusted_publishers].freeze
  EXCLUSIVE_SCOPES = %i[show_dashboard].freeze

  self.ignored_columns += API_SCOPES

  belongs_to :owner, polymorphic: true

  has_one :api_key_rubygem_scope, dependent: :destroy
  has_one :ownership, through: :api_key_rubygem_scope
  has_one :oidc_id_token, class_name: "OIDC::IdToken", dependent: :restrict_with_error
  has_one :oidc_api_key_role, class_name: "OIDC::ApiKeyRole", through: :oidc_id_token, source: :api_key_role, inverse_of: :api_keys
  has_many :pushed_versions, class_name: "Version", inverse_of: :pusher_api_key, foreign_key: :pusher_api_key_id, dependent: :nullify

  before_validation :set_owner_from_user
  after_create :record_create_event
  after_update :record_expire_event, if: :saved_change_to_expires_at?

  validates :name, :hashed_key, presence: true
  validate :exclusive_show_dashboard_scope, if: :can_show_dashboard?
  validate :scope_presence
  validates :name, length: { maximum: Gemcutter::MAX_FIELD_LENGTH }
  validates :expires_at, inclusion: { in: -> { 1.minute.from_now.. } }, allow_nil: true, on: :create
  validate :rubygem_scope_definition, if: :ownership
  validate :known_scopes
  validate :not_soft_deleted?
  validate :not_expired?

  delegate :rubygem_id, :rubygem, to: :ownership, allow_nil: true

  scope :unexpired, -> { where(arel_table[:expires_at].eq(nil).or(arel_table[:expires_at].gt(Time.now.utc))) }
  scope :expired, -> { where(arel_table[:expires_at].lteq(Time.now.utc)) }

  scope :oidc, -> { joins(:oidc_id_token) }
  scope :not_oidc, -> { where.missing(:oidc_id_token) }

  def self.expire_all!
    transaction do
      find_each.all?(&:expire!)
    end
  end

  API_SCOPES.each do |scope|
    define_method(:"can_#{scope}?") do
      scope_enabled = scopes.include?(scope)
      return scope_enabled if !scope_enabled || new_record?
      touch :last_accessed_at
    end
    alias_method scope, :"can_#{scope}?"
  end

  def scopes
    super&.map(&:to_sym) || []
  end

  def user
    owner if user?
  end

  def user?
    owner_type == "User"
  end

  delegate :mfa_required_not_yet_enabled?, :mfa_required_weak_level_enabled?,
    :mfa_recommended_not_yet_enabled?, :mfa_recommended_weak_level_enabled?,
    to: :user, allow_nil: true

  def mfa_authorized?(otp)
    return true unless mfa_enabled?
    return true if oidc_id_token.present?
    user.api_mfa_verified?(otp)
  end

  def mfa_enabled?
    return false unless user?
    return false unless user.mfa_enabled?
    return false if short_lived?
    user.mfa_ui_and_api? || mfa
  end

  def short_lived?
    return false unless created_at && expires_at
    (expires_at - created_at) < 15.minutes
  end

  def rubygem_id=(id)
    self.ownership = id.blank? ? nil : user.ownerships.find_by!(rubygem_id: id)
  rescue ActiveRecord::RecordNotFound
    errors.add :rubygem, "must be a gem that you are an owner of"
  end

  def rubygem_name=(name)
    self.rubygem_id = name.blank? ? nil : Rubygem.find_by_name!(name).id
  rescue ActiveRecord::RecordNotFound
    errors.add :rubygem, "could not be found"
  end

  def soft_delete!(ownership: nil)
    update_attribute(:soft_deleted_at, Time.now.utc)
    update_attribute(:soft_deleted_rubygem_name, ownership.rubygem.name) if ownership
  end

  def soft_deleted?
    soft_deleted_at?
  end

  def soft_deleted_by_ownership?
    soft_deleted? && soft_deleted_rubygem_name.present?
  end

  def expired?
    expires_at && expires_at <= Time.now.utc
  end

  def expire!
    transaction do
      update_column(:expires_at, Time.current)
      record_expire_event
    end
  end

  private

  def exclusive_show_dashboard_scope
    errors.add :show_dashboard, "scope must be enabled exclusively" if other_enabled_scopes?
  end

  def other_enabled_scopes?
    scopes.-(%i[show_dashboard]).any?
  end

  def scope_presence
    errors.add :base, "Please enable at least one scope" if scopes.blank?
  end

  def rubygem_scope_definition
    return if APPLICABLE_GEM_API_SCOPES.intersect?(scopes)
    errors.add :rubygem, "scope can only be set for push/yank rubygem, and add/remove owner scopes"
  end

  def known_scopes
    errors.add :scopes, "scopes must be from #{API_SCOPES}, got: #{scopes}" if (scopes - API_SCOPES).present?
  end

  def not_soft_deleted?
    errors.add :base, "An invalid API key cannot be used. Please delete it and create a new one." if soft_deleted?
  end

  def not_expired?
    return false if changed == %w[expires_at]
    errors.add :base, "An expired API key cannot be used. Please create a new one." if expired?
  end

  def set_owner_from_user
    self.owner ||= user
  end

  def record_create_event
    case owner
    when User
      user.record_event!(Events::UserEvent::API_KEY_CREATED,
          name:, scopes:, gem: rubygem&.name, mfa:, api_key_gid: to_gid)
    end
  end

  def record_expire_event
    case owner
    when User
      user.record_event!(Events::UserEvent::API_KEY_DELETED,
          name:, api_key_gid: to_gid)
    end
  end
end