#!/usr/bin/env ruby

require 'net/ldap'
require 'optparse'
require 'yaml'
require 'kwalify'
require 'pg'
require "pg_ldap_sync/logger"

module PgLdapSync
class Application
  attr_accessor :config_fname
  attr_accessor :log
  attr_accessor :test

  def string_to_symbol(hash)
    if hash.kind_of?(Hash)
      return hash.inject({}) do |h, v|
        raise "expected String instead of #{h.inspect}" unless v[0].kind_of?(String)
        h[v[0].intern] = string_to_symbol(v[1])
        h
      end
    else
      return hash
    end
  end


  def validate_config(config, schema, fname)
    schema = YAML.load_file(schema)
    validator = Kwalify::Validator.new(schema)
    errors = validator.validate(config)
    if errors && !errors.empty?
      errors.each do |err|
        log.fatal "error in #{fname}: [#{err.path}] #{err.message}"
      end
      raise InvalidConfig, 78 # EX_CONFIG
    end
  end

  def read_config_file(fname)
    raise "Config file #{fname.inspect} does not exist" unless File.exist?(fname)
    config = YAML.load(File.read(fname))

    schema_fname = File.join(File.dirname(__FILE__), '../../config/schema.yaml')
    validate_config(config, schema_fname, fname)

    @config = string_to_symbol(config)
  end

  LdapRole = Struct.new :name, :dn, :member_dns

  def search_ldap_users
    ldap_user_conf = @config[:ldap_users]
    name_attribute = ldap_user_conf[:name_attribute]

    users = []
    res = @ldap.search(
          base: ldap_user_conf[:base],
          filter: ldap_user_conf[:filter],
          attributes: [name_attribute, :dn]
    ) do |entry|
      name = entry[name_attribute].first

      unless name
        log.warn "user attribute #{name_attribute.inspect} not defined for #{entry.dn}"
        next
      end
      log.info "found user-dn: #{entry.dn}"

      names = if ldap_user_conf[:bothcase_name]
        [name, name.downcase].uniq
      elsif ldap_user_conf[:lowercase_name]
        [name.downcase]
      else
        [name]
      end

      names.each do |n|
        users << LdapRole.new(n, entry.dn)
      end
      entry.each do |attribute, values|
        log.debug "   #{attribute}:"
        values.each do |value|
          log.debug "      --->#{value.inspect}"
        end
      end
    end
    raise LdapError, "LDAP: #{@ldap.get_operation_result.message}" unless res
    return users
  end

  def retrieve_array_attribute(entry, attribute_name)
    array = entry[attribute_name]
    if array.empty?
      # Possibly an attribute, which must be retrieved in several ranges

      ranged_attr = entry.attribute_names.find { |n| n =~ /\A#{Regexp.escape(attribute_name)};range=/ }
      if ranged_attr
        entry_dn = entry.dn

        loop do
          array += entry[ranged_attr]
          log.debug "retrieved attribute range #{ranged_attr.inspect} of dn #{entry_dn}"

          if ranged_attr =~ /;range=\d+\-\*\z/
            break
          end

          attribute_with_range = ranged_attr.to_s.gsub(/;range=.*/, ";range=#{array.size}-*")
          entry = @ldap.search(
            base: entry_dn,
            scope: Net::LDAP::SearchScope_BaseObject,
            attributes: attribute_with_range).first

          ranged_attr = entry.attribute_names.find { |n| n =~ /\A#{Regexp.escape(attribute_name)};range=/ }
        end
      end
    else
      # Values already received -> No ranged attribute
    end
    return array
  end

  def search_ldap_groups
    ldap_group_conf = @config[:ldap_groups]
    name_attribute = ldap_group_conf[:name_attribute]
    member_attribute = ldap_group_conf[:member_attribute]

    groups = []
    res = @ldap.search(
          base: ldap_group_conf[:base],
          filter: ldap_group_conf[:filter],
          attributes: [name_attribute, member_attribute, :dn]
    ) do |entry|
      name = entry[name_attribute].first

      unless name
        log.warn "user attribute #{name_attribute.inspect} not defined for #{entry.dn}"
        next
      end

      log.info "found group-dn: #{entry.dn}"

      names = if ldap_group_conf[:bothcase_name]
        [name, name.downcase].uniq
      elsif ldap_group_conf[:lowercase_name]
        [name.downcase]
      else
        [name]
      end

      names.each do |n|
        group_members = retrieve_array_attribute(entry, member_attribute)
        groups << LdapRole.new(n, entry.dn, group_members)
      end
      entry.each do |attribute, values|
        log.debug "   #{attribute}:"
        values.each do |value|
          log.debug "      --->#{value.inspect}"
        end
      end
    end
    raise LdapError, "LDAP: #{@ldap.get_operation_result.message}" unless res
    return groups
  end

  PgRole = Struct.new :name, :member_names

  # List of default roles taken from https://www.postgresql.org/docs/current/predefined-roles.html
  PG_BUILTIN_ROLES = %w[ pg_read_all_data pg_write_all_data pg_read_all_settings pg_read_all_stats pg_stat_scan_tables pg_monitor pg_database_owner pg_signal_backend pg_read_server_files pg_write_server_files pg_execute_server_program pg_checkpoint pg_create_subscription pg_maintain pg_use_reserved_connections ]

  def search_pg_users
    pg_users_conf = @config[:pg_users]

    users = []
    res = pg_exec "SELECT rolname FROM pg_roles WHERE #{pg_users_conf[:filter]}"
    res.each do |tuple|
      user = PgRole.new tuple[0]
      next if PG_BUILTIN_ROLES.include?(user.name)
      log.info{ "found pg-user: #{user.name.inspect}"}
      users << user
    end
    return users
  end

  def search_pg_groups
    pg_groups_conf = @config[:pg_groups]

    groups = []
    res = pg_exec "SELECT rolname, oid FROM pg_roles WHERE #{pg_groups_conf[:filter]}"
    res.each do |tuple|
      res2 = pg_exec "SELECT pr.rolname FROM pg_auth_members pam JOIN pg_roles pr ON pr.oid=pam.member WHERE pam.roleid=#{@pgconn.escape_string(tuple[1])}"
      member_names = res2.map{|row| row[0] }
      group = PgRole.new tuple[0], member_names
      next if PG_BUILTIN_ROLES.include?(group.name)
      log.info{ "found pg-group: #{group.name.inspect} with members: #{member_names.inspect}"}
      groups << group
    end
    return groups
  end

  def uniq_names(list)
    names = {}
    new_list = list.select do |entry|
      name = entry.name
      if names[name]
        log.warn{ "duplicated group/user #{name.inspect} (#{entry.inspect})" }
        next false
      else
        names[name] = true
        next true
      end
    end
    return new_list
  end

  MatchedRole = Struct.new :ldap, :pg, :name, :state, :type

  def match_roles(ldaps, pgs, type)
    ldap_by_name = ldaps.inject({}){|h,u| h[u.name] = u; h }
    pg_by_name = pgs.inject({}){|h,u| h[u.name] = u; h }

    roles = []
    ldaps.each do |ld|
      pg = pg_by_name[ld.name]
      role = MatchedRole.new ld, pg, ld.name
      roles << role
    end
    pgs.each do |pg|
      ld = ldap_by_name[pg.name]
      next if ld
      role = MatchedRole.new ld, pg, pg.name
      roles << role
    end

    roles.each do |r|
      r.state = case
        when r.ldap && !r.pg then :create
        when !r.ldap && r.pg then :drop
        when r.pg && r.ldap then :keep
        else raise "invalid user #{r.inspect}"
      end
      r.type = type
    end

    log.info do
      roles.each do |role|
        log.debug{ "#{role.state} #{role.type}: #{role.name}" }
      end
      "#{type} stat: create: #{roles.count{|r| r.state==:create }} drop: #{roles.count{|r| r.state==:drop }} keep: #{roles.count{|r| r.state==:keep }}"
    end
    return roles
  end

  def try_sql(text)
    begin
      @pgconn.exec "SAVEPOINT try_sql;"
      @pgconn.exec text
    rescue PG::Error => err
      @pgconn.exec "ROLLBACK TO try_sql;"

      log.error{ "#{err} (#{err.class})" }
    end
  end

  def pg_exec_modify(sql)
    log.info{ "SQL: #{sql}" }
    unless self.test
      try_sql sql
    end
  end

  def pg_exec(sql)
    res = @pgconn.exec sql
    (0...res.num_tuples).map{|t| (0...res.num_fields).map{|i| res.getvalue(t, i) } }
  end

  def create_pg_role(role)
    pg_conf = @config[role.type==:user ? :pg_users : :pg_groups]
    pg_exec_modify "CREATE ROLE \"#{role.name}\" #{pg_conf[:create_options]}"
  end

  def drop_pg_role(role)
    pg_exec_modify "DROP ROLE \"#{role.name}\""
  end

  def sync_roles_to_pg(roles, for_state)
    roles.sort{|a,b| a.name<=>b.name }.each do |role|
      create_pg_role(role) if role.state==:create && for_state==:create
      drop_pg_role(role) if role.state==:drop && for_state==:drop
    end
  end

  MatchedMembership = Struct.new :role_name, :has_member, :state

  def match_memberships(ldap_roles, pg_roles)
    hash_of_arrays = Hash.new { |h, k| h[k] = [] }
    ldap_by_dn = ldap_roles.inject(hash_of_arrays){|h,r| h[r.dn] << r; h }
    ldap_by_m2m = ldap_roles.inject([]) do |a,r|
      next a unless r.member_dns
      a + r.member_dns.flat_map do |dn|
        has_members = ldap_by_dn[dn]
        log.warn{"ldap member with dn #{dn} is unknown"} if has_members.empty?
        has_members.map do |has_member|
          [r.name, has_member.name]
        end
      end
    end

    hash_of_arrays = Hash.new { |h, k| h[k] = [] }
    pg_by_name = pg_roles.inject(hash_of_arrays){|h,r| h[r.name] << r; h }
    pg_by_m2m = pg_roles.inject([]) do |a,r|
      next a unless r.member_names
      a + r.member_names.flat_map do |name|
        has_members = pg_by_name[name]
        log.warn{"pg member with name #{name} is unknown"} if has_members.empty?
        has_members.map do |has_member|
          [r.name, has_member.name]
        end
      end
    end

    memberships  = (ldap_by_m2m & pg_by_m2m).map{|r,mo| MatchedMembership.new r, mo, :keep }
    memberships += (ldap_by_m2m - pg_by_m2m).map{|r,mo| MatchedMembership.new r, mo, :grant }
    memberships += (pg_by_m2m - ldap_by_m2m).map{|r,mo| MatchedMembership.new r, mo, :revoke }

    log.info do
      memberships.each do |membership|
        log.debug{ "#{membership.state} #{membership.role_name} to #{membership.has_member}" }
      end
      "membership stat: grant: #{memberships.count{|u| u.state==:grant }} revoke: #{memberships.count{|u| u.state==:revoke }} keep: #{memberships.count{|u| u.state==:keep }}"
    end
    return memberships
  end

  def grant_membership(role_name, add_members)
    pg_conf = @config[:pg_groups]
    add_members_escaped = add_members.map{|m| "\"#{m}\"" }.join(",")
    pg_exec_modify "GRANT \"#{role_name}\" TO #{add_members_escaped} #{pg_conf[:grant_options]}"
  end

  def revoke_membership(role_name, rm_members)
    rm_members_escaped = rm_members.map{|m| "\"#{m}\"" }.join(",")
    pg_exec_modify "REVOKE \"#{role_name}\" FROM #{rm_members_escaped}"
  end

  def sync_membership_to_pg(memberships, for_state)
    grants = {}
    memberships.select{|ms| ms.state==for_state }.each do |ms|
      grants[ms.role_name] ||= []
      grants[ms.role_name] << ms.has_member
    end

    grants.each do |role_name, members|
      grant_membership(role_name, members) if for_state==:grant
      revoke_membership(role_name, members) if for_state==:revoke
    end
  end

  def start!
    read_config_file(@config_fname)

    ldap_conf = @config[:ldap_connection]
    auth_meth = ldap_conf.dig(:auth, :method).to_s
    if auth_meth == "gssapi"
      begin
        require 'net/ldap/auth_adapter/gssapi'
      rescue LoadError => err
        raise "#{err}\nTo use GSSAPI authentication please run:\n  gem install net-ldap-auth_adapter-gssapi"
      end
    elsif auth_meth == "gss_spnego"
      begin
        require 'net-ldap-gss-spnego'
        # This doesn't work since this file is defined in net-ldap as a placeholder:
        #   require 'net/ldap/auth_adapter/gss_spnego'
      rescue LoadError => err
        raise "#{err}\nTo use GSSAPI authentication please run:\n  gem install net-ldap-gss-spnego"
      end
    end

    # gather LDAP users and groups
    @ldap = Net::LDAP.new ldap_conf
    ldap_users = uniq_names search_ldap_users
    ldap_groups = uniq_names search_ldap_groups

    # gather PGs users and groups
    @pgconn = PG.connect @config[:pg_connection]
    begin
      @pgconn.transaction do
        pg_users = uniq_names search_pg_users
        pg_groups = uniq_names search_pg_groups

        # compare LDAP to PG users and groups
        mroles = match_roles(ldap_users, pg_users, :user)
        mroles += match_roles(ldap_groups, pg_groups, :group)

        # compare LDAP to PG memberships
        mmemberships = match_memberships(ldap_users+ldap_groups, pg_users+pg_groups)

        # drop/revoke roles/memberships first
        sync_membership_to_pg(mmemberships, :revoke)
        sync_roles_to_pg(mroles, :drop)
        # create/grant roles/memberships
        sync_roles_to_pg(mroles, :create)
        sync_membership_to_pg(mmemberships, :grant)
      end
    ensure
      @pgconn.close
    end

    # Determine exitcode
    if log.had_errors?
      raise ErrorExit.new(1, log.first_error)
    end
  end

  def self.run(argv)
    s = self.new
    s.config_fname = '/etc/pg_ldap_sync.yaml'
    s.log = Logger.new($stdout)
    s.log.level = Logger::ERROR

    OptionParser.new do |opts|
      opts.version = VERSION
      opts.banner = "Usage: #{$0} [options]"
      opts.on("-v", "--[no-]verbose", "Increase verbose level"){|v| s.log.level += v ? -1 : 1 }
      opts.on("-c", "--config FILE", "Config file [#{s.config_fname}]", &s.method(:config_fname=))
      opts.on("-t", "--[no-]test", "Don't do any change in the database", &s.method(:test=))

      opts.parse!(argv)
    end

    s.start!
  end
end
end
