CartoDB/cartodb20

View on GitHub
app/models/carto/user_creation.rb

Summary

Maintainability
D
2 days
Test Coverage
require 'cartodb-common'
require_dependency 'carto/user_authenticator'

class Carto::UserCreation < ActiveRecord::Base

  # Synced with CartoGearsApi::Events::UserCreationEvent
  CREATED_VIA_SAML = 'saml'.freeze
  CREATED_VIA_LDAP = 'ldap'.freeze
  CREATED_VIA_ORG_SIGNUP = 'org_signup'.freeze
  CREATED_VIA_API = 'api'.freeze
  CREATED_VIA_HTTP_AUTENTICATION = 'http_authentication'.freeze

  VALID_CREATED_VIA = [
    CREATED_VIA_LDAP, CREATED_VIA_SAML, CREATED_VIA_ORG_SIGNUP,
    CREATED_VIA_API, CREATED_VIA_HTTP_AUTENTICATION
  ].freeze

  IN_PROGRESS_STATES = [:initial, :enqueuing, :creating_user, :validating_user, :saving_user, :promoting_user, :load_common_data, :creating_user_in_central]
  FINAL_STATES = [:success, :failure]

  scope :http_authentication, -> { where(created_via: CREATED_VIA_HTTP_AUTENTICATION) }
  scope :in_progress, -> { where(state: IN_PROGRESS_STATES) }

  belongs_to :log, class_name: Carto::Log
  belongs_to :user, class_name: Carto::User

  after_create :use_invitation

  def self.new_user_signup(user, created_via = CREATED_VIA_ORG_SIGNUP)
    # Normal validation breaks state_machine method generation
    raise 'User needs username' unless user.username
    raise 'User needs email' unless user.email
    raise "Not valid #{created_via}: #{VALID_CREATED_VIA.join(', ')}" unless VALID_CREATED_VIA.include?(created_via)

    user_creation = Carto::UserCreation.new
    user_creation.username = user.username
    user_creation.email = user.email
    user_creation.crypted_password = user.crypted_password
    user_creation.session_salt = user.session_salt
    user_creation.organization_id = user.organization.nil? ? nil : user.organization.id
    user_creation.quota_in_bytes = user.quota_in_bytes
    user_creation.soft_geocoding_limit = user.soft_geocoding_limit
    user_creation.soft_here_isolines_limit = user.soft_here_isolines_limit
    user_creation.soft_twitter_datasource_limit = user.soft_twitter_datasource_limit.nil? ? false : user.soft_twitter_datasource_limit
    user_creation.soft_mapzen_routing_limit = user.soft_mapzen_routing_limit
    user_creation.google_sign_in = user.google_sign_in
    user_creation.github_user_id = user.github_user_id
    user_creation.log = Carto::Log.new_user_creation
    user_creation.created_via = created_via
    user_creation.viewer = user.viewer || false
    user_creation.org_admin = user.org_admin || false
    user_creation.last_password_change_date = user.last_password_change_date

    user_creation
  end

  state_machine :state, :initial => :enqueuing do

    before_transition any => any, :do => :log_transition_begin
    after_transition any => any, :do => :log_transition_end

    after_failure do
      self.fail_user_creation
    end

    after_transition any => :creating_user, :do => :initialize_user
    after_transition any => :validating_user, :do => :validate_user
    after_transition any => :saving_user, :do => :save_user
    after_transition any => :promoting_user, :do => :promote_user
    after_transition any => :load_common_data, :do => :load_common_data
    after_transition any => :creating_user_in_central, :do => :create_in_central

    before_transition any => :success, :do => :close_creation
    before_transition any => :failure, :do => :clean_user

    event :next_creation_step do
      transition :enqueuing => :creating_user,
          :creating_user => :validating_user,
          :validating_user => :saving_user,
          :saving_user => :promoting_user

      # This looks more complex than it actually is. The flow is always:
      # promoting_user -> creating_user_in_central -> load_common_data -> success
      #   creating_user_in_central is skipped if central is not configured
      #   load_common_data is skipped for viewers
      transition promoting_user: :creating_user_in_central, if: -> { Cartodb::Central.message_broker_sync_enabled? }
      transition promoting_user: :load_common_data, unless: :viewer?
      transition promoting_user: :success

      transition creating_user_in_central: :load_common_data, unless: :viewer?
      transition creating_user_in_central: :success

      transition load_common_data: :success
    end

    event :fail_user_creation do
      transition any => :failure
    end

    state all - [:success, :failure] do
      def finished?
        false
      end
    end

    state :success, :failure do
      def finished?
        true
      end
    end

  end

  def set_owner_promotion(promote_to_organization_owner)
    @promote_to_organization_owner = promote_to_organization_owner
  end

  def set_common_data_url(common_data_url)
    @common_data_url = common_data_url
  end

  # TODO: Shorcut, search for a better solution to detect requirement
  def requires_validation_email?
    google_sign_in != true &&
      !github_user_id &&
      !has_valid_invitation? &&
      !Carto::Ldap::Manager.new.configuration_present? &&
      !created_via_api? &&
      !created_via_http_authentication? &&
      !created_via_saml?
  end

  def autologin?
    state == 'success' && created_at > Time.now - 1.minute && enabled? && cartodb_user.dashboard_viewed_at.nil?
  end

  def subdomain
    cartodb_user.subdomain
  end

  def with_invitation_token(invitation_token)
    self.invitation_token = invitation_token
    self
  end

  def created_via_api?
    created_via == CREATED_VIA_API
  end

  def created_via_http_authentication?
    created_via == CREATED_VIA_HTTP_AUTENTICATION
  end

  def created_via_saml?
    created_via == CREATED_VIA_SAML
  end

  def has_valid_invitation?
    return false unless invitation_token
    !valid_invitation.nil?
  end

  def pertinent_invitation
    @pertinent_invitation ||= select_valid_invitation_token(Carto::Invitation.query_with_unused_email(email).all)
  end

  def valid_invitation
    @valid_invitation ||= select_valid_invitation_token(Carto::Invitation.query_with_valid_email(email).all)
  end

  private

  def enabled?
    cartodb_user.enable_account_token.nil? && cartodb_user.enabled
  end

  # Returns the first matching token invitation, and raises error if none is found
  # but a token is set, since it might be fake
  def select_valid_invitation_token(invitations)
    return nil if invitations.empty?

    invitation = invitations.select do |i|
      i.token(email) == invitation_token &&
      organization_id == i.organization_id
    end.first

    raise "Fake token sent for email #{email}, #{invitation_token}" if invitation_token && invitation.nil?

    invitation
  end

  def cartodb_user
    @cartodb_user ||= ::User.where(id: user_id).first
  end

  def log_transition_begin
    log_transition('Beginning')
  end

  def log_transition_end
    log_transition('End')
  end

  def log_transition(prefix)
    log.append("#{prefix}: State: #{state}")
  end

  def initialize_user
    @cartodb_user = ::User.new
    @cartodb_user.username = username
    @cartodb_user.email = email
    @cartodb_user.crypted_password = crypted_password
    @cartodb_user.session_salt = session_salt
    @cartodb_user.google_sign_in = google_sign_in
    @cartodb_user.github_user_id = github_user_id
    @cartodb_user.invitation_token = invitation_token
    @cartodb_user.enable_account_token = Carto::Common::EncryptionService.make_token if requires_validation_email?

    unless organization_id.nil? || @promote_to_organization_owner
      organization = Carto::Organization.find_by(id: organization_id)
      raise "Trying to copy organization settings from one without owner" if organization.owner.nil?
      @cartodb_user.organization = organization
      @cartodb_user.organization.owner.sequel_user.copy_account_features(@cartodb_user)
    end

    @cartodb_user.quota_in_bytes = quota_in_bytes unless quota_in_bytes.nil?
    @cartodb_user.soft_geocoding_limit = soft_geocoding_limit unless soft_geocoding_limit.nil?
    @cartodb_user.soft_here_isolines_limit = soft_here_isolines_limit unless soft_here_isolines_limit.nil?
    @cartodb_user.soft_twitter_datasource_limit = soft_twitter_datasource_limit unless soft_twitter_datasource_limit.nil?
    @cartodb_user.soft_mapzen_routing_limit = soft_mapzen_routing_limit unless soft_mapzen_routing_limit.nil?
    @cartodb_user.viewer = viewer if viewer
    @cartodb_user.org_admin = org_admin if org_admin
    @cartodb_user.last_password_change_date = last_password_change_date unless last_password_change_date.nil?

    if valid_invitation
      @cartodb_user.viewer = valid_invitation.viewer
    end

    @cartodb_user
  rescue StandardError => e
    handle_failure(e, mark_as_failure = true)
    nil
  end

  # Central validation
  def validate_user
    @cartodb_user.validate_credentials_not_taken_in_central
    raise "Credentials already used" unless @cartodb_user.errors.empty?
  rescue StandardError => e
    handle_failure(e, mark_as_failure = true)
  end

  def save_user
    # INFO: until here we haven't user_id, so in-memory @cartodb_user is needed.
    # After this, self.cartodb_user is used, which can be either @cartodb_user or loaded from database.
    # This enables resuming.
    @cartodb_user.save(raise_on_failure: true)
    self.user_id = @cartodb_user.id
    self.save
  rescue StandardError => e
    handle_failure(e, mark_as_failure = true)
  end

  def use_invitation
    return unless invitation_token
    invitation = pertinent_invitation
    return unless invitation

    invitation.use(email, invitation_token)
  end

  def promote_user
    return unless @promote_to_organization_owner

    organization = Carto::Organization.find_by(id: organization_id)
    raise "Trying to set organization owner when there's already one" unless organization.owner.nil?

    user_organization = CartoDB::UserOrganization.new(organization_id, @cartodb_user.id)
    user_organization.promote_user_to_admin
    @cartodb_user.reload
  rescue StandardError => e
    handle_failure(e, mark_as_failure = true)
  end

  def load_common_data
    @cartodb_user.load_common_data(@common_data_url) unless @common_data_url.nil?
  rescue StandardError => e
    handle_failure(e, mark_as_failure = false)
  end

  def create_in_central
    cartodb_user.create_in_central
  rescue StandardError => e
    handle_failure(e, mark_as_failure = true)
  end

  def close_creation
    clean_password
    cartodb_user.notify_new_organization_user unless has_valid_invitation?
    cartodb_user.organization.notify_if_disk_quota_limit_reached if cartodb_user.organization && !cartodb_user.viewer?
    cartodb_user.organization.notify_if_seat_limit_reached if cartodb_user.organization && !cartodb_user.viewer?
    CartoGearsApi::Events::EventManager.instance.notify(
      CartoGearsApi::Events::UserCreationEvent.new(created_via, cartodb_user)
    )
  rescue StandardError => e
    handle_failure(e, mark_as_failure = false)
  end

  def handle_failure(e, mark_as_failure)
    log_error(exception: e, user_creation_id: id, mark_as_failure: mark_as_failure)
    self.log.append("Error on state #{self.state}, mark_as_failure: #{mark_as_failure}. Error: #{e.message}")
    self.log.append(e.backtrace.join("\n"))

    process_failure if mark_as_failure
  end

  def process_failure
    self.state = 'failure'
    self.save
    self.fail_user_creation
  end

  def clean_user
    return unless cartodb_user && !cartodb_user.id.nil?

    begin
      cartodb_user.destroy
    rescue StandardError => e
      CartoDB.notify_exception(e, action: 'safe user destruction', user: cartodb_user)
      begin
        cartodb_user.delete
      rescue StandardError => ee
        CartoDB.notify_exception(ee, action: 'safe user deletion', user: cartodb_user)
      end

    end
  end

  def clean_password
    self.crypted_password = ''
    self.save
  end
end