thorin/redmine_ldap_sync

View on GitHub
lib/ldap_sync/infectors/auth_source_ldap.rb

Summary

Maintainability
F
3 days
Test Coverage
# encoding: utf-8
# Copyright (C) 2011-2013  The Redmine LDAP Sync Authors
#
# This file is part of Redmine LDAP Sync.
#
# Redmine LDAP Sync is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Redmine LDAP Sync is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Redmine LDAP Sync.  If not, see <http://www.gnu.org/licenses/>.
module LdapSync::Infectors::AuthSourceLdap

  module InstanceMethods
    include LdapSync::EntityManager

    public
    def sync_groups
      if connect_as_user?
        trace "   -> Cannot synchronize: no account/password configured"; return
      end
      unless setting.active?
        trace "   -> Ldap sync is disabled: skipping"; return
      end
      unless setting.sync_group_fields? || setting.create_groups? || setting.sync_dyngroups?
        trace "   -> No attributes to sync: skipping"; return
      end

      with_ldap_connection do |ldap|
        trace "** Synchronizing non-dynamic groups"
        attrs = [n(:groupname), *setting.group_ldap_attrs_to_sync]
        find_all_groups(ldap, nil, attrs) do |entry|
          create_and_sync_group(entry, n(:groupname))
        end if setting.sync_group_fields? || setting.create_groups?

        return unless setting.sync_dyngroups?

        trace "** Synchronizing dynamic groups"
        find_all_dyngroups(ldap,
          :attrs => [:cn, :member, *setting.group_ldap_attrs_to_sync],
          :update_cache => !dyngroups_fresh?) do |entry|
          create_and_sync_group(entry, :cn)
        end
      end
    end

    def sync_users
      if connect_as_user?
        trace "   -> Cannot synchronize: no account/password configured"; return
      end
      unless setting.active?
        trace "   -> Ldap sync is disabled: skipping"; return
      end

      @closure_cache = new_memory_cache if setting.nested_groups_enabled?

      with_ldap_connection do |_|
        ldap_users[:locked].each do |login|
          user = self.users.where("LOWER(login) = ?", login.mb_chars.downcase).first

          if user.try(:active?)
            if user.lock!
              change user.login, "-- Locked active user '#{user.login}' (#{user.name})"
            else
              change user.login, "-- Failed to lock active user '#{user.login}'"
            end
          elsif user.present?
            trace "-- Not locking locked user '#{user.login}'"
          end
          user, = find_local_user(login)
          sync_user(user, false, :locked => true) if user.present?
        end

        ldap_users[:deleted].each do |login|
          user = self.users.where("LOWER(login) = ?", login.mb_chars.downcase).first

          if user.try(:active?)
            if user.archive!
              change user.login, "-- Archived active user '#{user.login}' (#{user.name})"
            else
              change user.login, "-- Failed to archive active user '#{user.login}'"
            end
          elsif user.present?
            trace "-- Not archiving locked user '#{user.login}'"
          end
        end

        ldap_users[:enabled].each do |login|
          user, is_new_user = find_or_create_user(login)
          sync_user(user, is_new_user) if user.present?
        end
      end

      update_closure_cache! if setting.nested_groups_enabled?
    end

    def sync_user(user, is_new_user = false, options = {})
      with_ldap_connection(options[:login], options[:password]) do |ldap|
        trace "-- #{is_new_user ? 'Creating' : 'Updating'} user '#{user.login}' (#{user.name})...",
          :level => is_new_user ? :change : :debug,
          :obj => user.login

        sync_groups = !options[:try_to_login] || setting.sync_groups_on_login?
        sync_fields = !is_new_user && (!options[:try_to_login] || setting.sync_fields_on_login?)

        user_data, flags = if options[:try_to_login] && setting.has_account_flags? && sync_fields
          user_data = find_user(ldap, user.login, setting.user_ldap_attrs_to_sync + ns(:account_flags))
          [user_data, user_data.present? ? user_data[n(:account_flags)].first : :deleted]
        end

        sync_user_groups(user) if sync_groups
        sync_user_status(user, flags, options[:locked] || false)

        return if user.locked?

        sync_admin_privilege(user)
        sync_user_fields(user, user_data) if sync_fields
      end
    end

    def locked_on_ldap?(user, options = {})
      with_ldap_connection(options[:login], options[:password]) do |ldap|
        locked = if setting.has_account_flags? && setting.sync_fields_on_login?
          flags = find_user(ldap, user.login, n(:account_flags)).first
          account_locked?(flags)
        end

        locked ||= if setting.has_required_group? && setting.sync_groups_on_login?
          user_groups = groups_changes(user)[:added].map(&:downcase)
          !user_groups.include?(setting.required_group.downcase)
        end

        locked || false
      end
    end

    private
      def create_and_sync_group(group_data, attr_groupname)
        groupname = group_data[attr_groupname].first
        return unless setting.groupname_regexp =~ groupname

        group, is_new_group = find_or_create_group(groupname, group_data)
        return if group.nil?

        trace "-- #{is_new_group ? 'Creating' : 'Updating'} group '#{group.name}'...",
          :level => is_new_group ? :change : :debug,
          :obj => group.name
        sync_group_fields(group, group_data) unless is_new_group

        group
      end

      def sync_user_groups(user)
        return unless setting.active?

        if setting.has_fixed_group? && !user.member_of_group?(setting.fixed_group)
          user.add_to_fixed_group
        end

        changes = groups_changes(user)
        added = changes[:added].map {|g| find_or_create_group(g).first }.compact
        user.groups << added unless added.empty?

        deleted_groups = changes[:deleted].map {|g| g.mb_chars.downcase }
        deleted = deleted_groups.any? ? ::Group.where("LOWER(lastname) in (?)", deleted_groups).to_a : []
        user.groups.delete(*deleted) unless deleted.empty?

        trace_groups_changes_summary(user, changes, added, deleted)
      end

      def sync_user_fields(user, user_data = nil)
        return unless setting.active? && setting.sync_user_fields?

        user.synced_fields = get_user_fields(user.login, user_data)

        if user.save
          user
        else
          error_message = if user.email_is_taken
            mail_owner = User.find_by_mail(user.mail)
            fmt = User.name_formatter[:firstname_lastname]
            "email already taken by #{mail_owner.name(fmt)} (#{mail_owner.login})"
          else
            "#{user.errors.full_messages.join('", "')}"
          end
          error "Could not sync user '#{user.login}': \"#{error_message}\""; nil
        end
      end

      def sync_user_status(user, flags, locked)
        locked ||= flags && flags != :deleted && account_locked?(flags)
        deleted = flags == :deleted

        message = if user.active?
          if deleted
            user.archive!
            "   -> archived: user deleted on ldap"
          elsif locked
            user.lock!
            "   -> locked: user locked on ldap with flags '#{flags}'"
          elsif setting.has_required_group?
            unless user.member_of_group?(setting.required_group)
              user.lock!
              "   -> locked: not member of group '#{setting.required_group}'"
            end
          end
        elsif user.locked?
          unless setting.has_required_group? && !user.member_of_group?(setting.required_group) || flags && (deleted || locked)
            if setting.has_required_group?
              user.activate!
              "   -> activated: member of group '#{setting.required_group}'"
            elsif activate_users? && (!flags || !(deleted || locked))
              user.activate!
              "   -> activated: ACTIVATE_USERS flag is on"
            end
          end
        end
        change user.login, message if message
      end

      def sync_admin_privilege(user)
        return unless setting.has_admin_group?

        if user.member_of_group?(setting.admin_group)
          unless user.admin?
            user.set_admin!
            change user.login, "   -> granted admin privileges: member of group '#{setting.admin_group}'"
          end
        else
          if user.admin?
            user.unset_admin!
            change user.login, "   -> revoked admin privileges: not member of group '#{setting.admin_group}'"
          end
        end
      end

      def sync_group_fields(group, group_data)
        group.synced_fields = get_group_fields(group.name, group_data)

        if group.save
          group
        else
          change group.name, "-- Could not sync group '#{group.lastname}': \"#{group.errors.full_messages.join('", "')}\""; nil
        end
      end

      def find_or_create_group(groupname, group_data = nil)
        group = ::Group.where("LOWER(lastname) = ?", groupname.mb_chars.downcase).first
        return group, false unless group.nil? && setting.create_groups?

        group = ::Group.new(:lastname => groupname, :auth_source_id => self.id) do |g|
          g.set_default_values
          g.synced_fields = get_group_fields(groupname, group_data)
        end

        if group.save
          return group, true
        else
          change group.name, "Could not create group '#{groupname}': \"#{group.errors.full_messages.join('", "')}\""
          return nil, false
        end
      end

      def find_local_user(username)
        user = ::User.where("LOWER(#{User.table_name}.login) = ?", username.mb_chars.downcase).first
        if user.present? && user.auth_source_id != self.id
          trace "-- Skipping user '#{user.login}': it already exists on a different auth_source"
          return nil, true
        end
        return user, false
      end

      def find_or_create_user(username)
        user, is_invalid = find_local_user(username)
        return nil, false if is_invalid
        return user unless user.nil? && setting.create_users?

        user = ::User.new do |u|
          u.login = username
          u.set_default_values
          u.synced_fields = get_user_fields(username, nil, :include_required => true)
          u.auth_source_id = self.id
        end

        if user.save
          return user, true
        else
          error_message = if user.email_is_taken
            mail_owner = User.find_by_mail(user.mail)
            fmt = User.name_formatter[:firstname_lastname]
            "email already taken by #{mail_owner.name(fmt)} (#{mail_owner.login})"
          else
            "#{user.errors.full_messages.join('", "')}"
          end
          change user.login, "-- Could not create user '#{user.login}': \"#{error_message}\""; nil
        end
      end

      def new_memory_cache
        cache = Hash.new
        def cache.fetch(key, &block)
          self[key] = super(key, &block)
        end
        cache
      end

      def parents_cache
        @parents_cache ||= ActiveSupport::Cache.lookup_store(:memory_store)
      end

      def reset_parents_cache!
        @parents_cache.clear unless @parents_cache.nil?
      end

      def dyngroups_fresh?
        if running_rake?
          !dyngroups_updated?
        else
          opts = {}
          if setting.dyngroups_enabled_with_ttl?
            # We do a TTL bump here to reduce the load on LDAP
            opts[:race_condition_ttl] = 5.minutes
            opts[:expires_in] = setting.dyngroups_cache_ttl.to_f.minutes
          end

          expired = false
          dyngroups_cache.fetch(:cache_control, opts) { expired = true }
          !expired
        end
      end

      def cache_root
        root_path = Rails.root.join("tmp/ldap_cache/#{self.id}")
        FileUtils.mkdir_p root_path unless File.exist? root_path

        root_path
      end

      def closure_cache
        @closure_cache ||= ActiveSupport::Cache.lookup_store(:file_store, "#{cache_root}/nested_groups")
      end

      def dyngroups_cache
        @dyngroups_cache ||= ActiveSupport::Cache.lookup_store(:file_store, "#{cache_root}/dyngroups")
      end

      def update_closure_cache!
        disk_cache = ActiveSupport::Cache.lookup_store(:file_store, "#{cache_root}/nested_groups")
        mem_cache = @closure_cache

        # Match all the entries we want to delete
        disk_cache.delete_unless {|k| mem_cache.has_key?(k) }
        mem_cache.each {|k, v| disk_cache.write(k, v) }
      end

      def update_dyngroups_cache!(mem_cache)
        trace do
          mem_cache.sort_by {|u, m| u}.reduce("   update_dyngroups_cache\n") do |t, (u, m)|
            t << "      #{u} #{m.join(', ')}\n"
          end
        end

        opts = {}
        if setting.dyngroups_enabled_with_ttl?
          opts[:race_condition_ttl] = 5.minutes
          opts[:expires_in] = setting.dyngroups_cache_ttl.to_f.minutes
        end
        dyngroups_cache.write(:cache_control, true, opts)

        dyngroups_cache.delete_unless {|k| k == 'cache_control' || mem_cache.has_key?(k) }
        mem_cache.each {|k, v| dyngroups_cache.write(k, v) }

        self.dyngroups_updated = true
      end

      def setting
        @setting ||= LdapSetting.find_by_auth_source_ldap_id(self.id)
      end

      def pluralize(n, word)
        word.present? ? "#{n} #{word}#{'s' if n != 1}" : n.to_s
      end

      def trace_groups_changes_summary(user, groups, added, deleted)
        return unless running_rake?

        a = added.size; d = deleted.size; nc = groups[:added].size - a
        added_names = added.map {|g| g.lastname.mb_chars.downcase.to_s }
        deleted_names = deleted.map {|g| g.lastname.mb_chars.downcase.to_s }

        nc_names = groups[:added].map {|g| g.mb_chars.downcase.to_s } - added_names

        chg = []
        chg << "#{pluralize(a, 'group')} added (#{added_names.join(', ')})" if a > 0
        chg << "#{pluralize(d, a == 0 ? 'group' : nil)} deleted (#{deleted_names.join(', ')})" if d > 0
        chg << "#{pluralize(nc, a + d == 0 ? 'group' : nil)} already created (#{ nc_names.to_a.join(', ') })" if nc > 0

        msg = if chg.size == 1
          "   -> #{chg[0]}"
        elsif chg.size > 1
          "   -> #{[chg[0...-1].join(', '), chg[-1]].join(' and ')}"
        end

        level = a > 0 || d > 0 ? :change : :debug
        trace msg, :level => level, :obj => user.login
      end

      def trace(msg = nil, options = {}, &block)
        return if trace_level == :silent || msg.nil?
        if !running_rake?
          logger.error(block_given? ? yield : msg) if options[:level] == :error
          return
        end

        options.reverse_merge!(:level => :debug)

        msg = block_given? ? yield : msg
        case options[:level]
        when :error; puts "-- #{msg}"
        when :debug; puts msg unless [:change, :error].include? trace_level
        when :info;  puts msg unless [:error].include? trace_level
        when :change
          if trace_level == :change && !options[:obj].nil?
            obj = options[:obj]
            trace_msg = msg.gsub(/^.*?(\w)/, '\1').
                gsub('...', '').
                gsub(/ '#{obj}'/, '').
                downcase
            puts "[#{obj}] #{trace_msg}"
          else
            puts msg unless [:error].include? trace_level
          end
        end
      end

      def dyngroups_updated?; self.dyngroups_updated; end
      def activate_users?; self.activate_users; end
      def running_rake?; self.running_rake; end
  end

  module ClassMethods
    def activate_users!
      self.activate_users = true
    end

    def running_rake!
      self.running_rake = true
    end
  end

  def self.included(receiver)
    receiver.extend(ClassMethods)
    receiver.send(:include, InstanceMethods)

    receiver.instance_eval do
      delegate :has_fixed_group?, :fixed_group, :sync_on_login?, :to => :setting, :allow_nil => true
      cattr_accessor :activate_users, :running_rake, :dyngroups_updated
      cattr_accessor :trace_level do
        :debug
      end
      unloadable
    end
  end
end