#
# Author:: Seth Chisamore (<schisamo@chef.io>)
# Author:: Sean OMeara (<sean@sean.io>)
# Copyright:: 2011-2016, Chef Software, Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#

require File.join(File.dirname(__FILE__), 'provider_database_mysql')

class Chef
  class Provider
    class Database
      class MysqlUser < Chef::Provider::Database::Mysql
        use_inline_resources

        def whyrun_supported?
          true
        end

        action :create do
          # test
          user_present = nil
          begin
            test_sql = "SELECT User,Host from mysql.user WHERE User='#{new_resource.username}' AND Host='#{new_resource.host}';"
            test_sql_results = test_client.query(test_sql)
            test_sql_results.each do |r|
              user_present = true if r['User'] == new_resource.username
            end

            password_up_to_date = !user_present || test_user_password
          ensure
            close_test_client
          end

          # repair
          unless user_present
            converge_by "Creating user '#{new_resource.username}'@'#{new_resource.host}'" do
              begin
                repair_sql = "CREATE USER '#{new_resource.username}'@'#{new_resource.host}'"
                if new_resource.password
                  repair_sql += ' IDENTIFIED BY '
                  repair_sql += if new_resource.password.is_a?(HashedPassword)
                                  " PASSWORD '#{new_resource.password}'"
                                else
                                  " '#{new_resource.password}'"
                                end
                end
                repair_client.query(repair_sql)
              ensure
                close_repair_client
              end
            end
          end

          update_user_password unless password_up_to_date
        end

        action :drop do
          # test
          user_present = nil
          begin
            test_sql = 'SELECT User,Host'
            test_sql += ' from mysql.user'
            test_sql += " WHERE User='#{new_resource.username}'"
            test_sql += " AND Host='#{new_resource.host}'"
            test_sql_results = test_client.query test_sql
            test_sql_results.each do |r|
              user_present = true if r['User'] == new_resource.username
            end
          ensure
            close_test_client
          end

          # repair
          if user_present
            converge_by "Dropping user '#{new_resource.username}'@'#{new_resource.host}'" do
              begin
                repair_sql = 'DROP USER'
                repair_sql += " '#{new_resource.username}'@'#{new_resource.host}'"
                repair_client.query repair_sql
              ensure
                close_repair_client
              end
            end
          end
        end

        action :grant do
          # gratuitous function
          def ishash?
            return true if /(\A\*[0-9A-F]{40}\z)/i =~ new_resource.password
          end

          db_name = new_resource.database_name ? "`#{new_resource.database_name}`" : '*'
          tbl_name = new_resource.table ? new_resource.table : '*'
          test_table = new_resource.database_name ? 'mysql.db' : 'mysql.user'

          # Test
          incorrect_privs = nil
          begin
            test_sql = "SELECT * from #{test_table}"
            test_sql += " WHERE User='#{new_resource.username}'"
            test_sql += " AND Host='#{new_resource.host}'"
            test_sql += " AND Db='#{new_resource.database_name}'" if new_resource.database_name
            test_sql_results = test_client.query test_sql

            incorrect_privs = true if test_sql_results.size.zero?
            # These should all be 'Y'
            test_sql_results.each do |r|
              desired_privs.each do |p|
                key = p.to_s.capitalize.tr(' ', '_').gsub('Replication_', 'Repl_').gsub('Create_temporary_tables', 'Create_tmp_table').gsub('Show_databases', 'Show_db')
                key = "#{key}_priv"
                incorrect_privs = true if r[key] != 'Y'
              end
            end

            password_up_to_date = incorrect_privs || test_user_password
          ensure
            close_test_client
          end

          # Repair
          if incorrect_privs
            converge_by "Granting privs for '#{new_resource.username}'@'#{new_resource.host}'" do
              begin
                repair_sql = "GRANT #{new_resource.privileges.join(',')}"
                repair_sql += " ON #{db_name}.#{tbl_name}"
                repair_sql += " TO '#{new_resource.username}'@'#{new_resource.host}' IDENTIFIED BY"
                repair_sql += if new_resource.password.is_a?(HashedPassword)
                                " PASSWORD '#{new_resource.password}'"
                              else
                                " '#{new_resource.password}'"
                              end
                repair_sql += ' REQUIRE SSL' if new_resource.require_ssl
                repair_sql += ' REQUIRE X509' if new_resource.require_x509
                repair_sql += ' WITH GRANT OPTION' if new_resource.grant_option

                redacted_sql = redact_password(repair_sql, new_resource.password)
                Chef::Log.debug("#{@new_resource}: granting with sql [#{redacted_sql}]")
                repair_client.query(repair_sql)
                repair_client.query('FLUSH PRIVILEGES')
              ensure
                close_repair_client
              end
            end
          else
            # The grants are correct, but perhaps the password needs updating?
            update_user_password unless password_up_to_date
          end
        end

        action :revoke do
          db_name = new_resource.database_name ? "`#{new_resource.database_name}`" : '*'
          tbl_name = new_resource.table ? new_resource.table : '*'
          test_table = new_resource.database_name ? 'mysql.db' : 'mysql.user'

          privs_to_revoke = []
          begin
            test_sql = "SELECT * from #{test_table}"
            test_sql += " WHERE User='#{new_resource.username}'"
            test_sql += " AND Host='#{new_resource.host}'"
            test_sql += " AND Db='#{new_resource.database_name}'" if new_resource.database_name
            test_sql_results = test_client.query test_sql

            # These should all be 'N'
            test_sql_results.each do |r|
              desired_privs.each do |p|
                key = p.to_s.capitalize.tr(' ', '_').gsub('Replication_', 'Repl_').gsub('Create_temporary_tables', 'Create_tmp_table').gsub('Show_databases', 'Show_db')
                key = "#{key}_priv"
                privs_to_revoke << revokify_key(p) if r[key] != 'N'
              end
            end
          ensure
            close_test_client
          end

          # Repair
          unless privs_to_revoke.empty?
            converge_by "Revoking privs for '#{new_resource.username}'@'#{new_resource.host}'" do
              begin
                revoke_statement = "REVOKE #{privs_to_revoke.join(',')}"
                revoke_statement += " ON #{db_name}.#{tbl_name}"
                revoke_statement += " FROM `#{@new_resource.username}`@`#{@new_resource.host}` "

                Chef::Log.debug("#{@new_resource}: revoking access with statement [#{revoke_statement}]")
                repair_client.query(revoke_statement)
                repair_client.query('FLUSH PRIVILEGES')
                @new_resource.updated_by_last_action(true)
              ensure
                close_repair_client
              end
            end
          end
        end

        private

        def desired_privs
          possible_global_privs = [
            :select,
            :insert,
            :update,
            :delete,
            :create,
            :drop,
            :references,
            :index,
            :alter,
            :create_tmp_table,
            :lock_tables,
            :create_view,
            :show_view,
            :create_routine,
            :alter_routine,
            :execute,
            :event,
            :trigger,
            :reload,
            :shutdown,
            :process,
            :file,
            :show_db,
            :super,
            :repl_slave,
            :repl_client,
            :create_user,
          ]
          possible_db_privs = [
            :select,
            :insert,
            :update,
            :delete,
            :create,
            :drop,
            :references,
            :index,
            :alter,
            :create_tmp_table,
            :lock_tables,
            :create_view,
            :show_view,
            :create_routine,
            :alter_routine,
            :execute,
            :event,
            :trigger,
          ]

          # convert :all to the individual db or global privs
          desired_privs = if new_resource.privileges == [:all] && new_resource.database_name
                            possible_db_privs
                          elsif new_resource.privileges == [:all]
                            possible_global_privs
                          else
                            new_resource.privileges
                          end
          desired_privs
        end

        def test_client
          require 'mysql2'
          @test_client ||=
            Mysql2::Client.new(
              host: new_resource.connection[:host],
              socket: new_resource.connection[:socket],
              username: new_resource.connection[:username],
              password: new_resource.connection[:password],
              port: new_resource.connection[:port],
              default_file: new_resource.connection[:default_file],
              default_group: new_resource.connection[:default_group]
            )
        end

        def close_test_client
          @test_client.close if @test_client
        rescue Mysql2::Error
          @test_client = nil
        end

        def repair_client
          require 'mysql2'
          @repair_client ||=
            Mysql2::Client.new(
              host: new_resource.connection[:host],
              socket: new_resource.connection[:socket],
              username: new_resource.connection[:username],
              password: new_resource.connection[:password],
              port: new_resource.connection[:port],
              default_file: new_resource.connection[:default_file],
              default_group: new_resource.connection[:default_group]
            )
        end

        def close_repair_client
          @repair_client.close if @repair_client
        rescue Mysql2::Error
          @repair_client = nil
        end

        def revokify_key(key)
          return '' if key.nil?

          # Some keys need to be translated as outlined by the table found here:
          # https://dev.mysql.com/doc/refman/5.7/en/privileges-provided.html
          result = key.to_s.downcase.tr('_', ' ').gsub('repl ', 'replication ').gsub('create tmp table', 'create temporary tables').gsub('show db', 'show databases')
          result = result.gsub(/ priv$/, '')
          result
        end

        def test_user_password
          if database_has_password_column(test_client)
            test_sql = 'SELECT User,Host,Password FROM mysql.user ' \
                       "WHERE User='#{new_resource.username}' AND Host='#{new_resource.host}' "
            test_sql += if new_resource.password.is_a? HashedPassword
                          "AND Password='#{new_resource.password}'"
                        else
                          "AND Password=PASSWORD('#{new_resource.password}')"
                        end
          else
            test_sql = 'SELECT User,Host,authentication_string FROM mysql.user ' \
                       "WHERE User='#{new_resource.username}' AND Host='#{new_resource.host}' " \
                       "AND plugin='mysql_native_password' "
            test_sql += if new_resource.password.is_a? HashedPassword
                          "AND authentication_string='#{new_resource.password}'"
                        else
                          "AND authentication_string=PASSWORD('#{new_resource.password}')"
                        end
          end
          test_client.query(test_sql).size > 0
        end

        def update_user_password
          converge_by "Updating password of user '#{new_resource.username}'@'#{new_resource.host}'" do
            begin
              if database_has_password_column(repair_client)
                repair_sql = "SET PASSWORD FOR '#{new_resource.username}'@'#{new_resource.host}' = "
                repair_sql += if new_resource.password.is_a? HashedPassword
                                "'#{new_resource.password}'"
                              else
                                " PASSWORD('#{new_resource.password}')"
                              end
              else
                # "ALTER USER is now the preferred statement for assigning passwords."
                # http://dev.mysql.com/doc/refman/5.7/en/set-password.html
                repair_sql = "ALTER USER '#{new_resource.username}'@'#{new_resource.host}' "
                repair_sql += if new_resource.password.is_a? HashedPassword
                                "IDENTIFIED WITH mysql_native_password AS '#{new_resource.password}'"
                              else
                                "IDENTIFIED BY '#{new_resource.password}'"
                              end
              end
              repair_client.query(repair_sql)
            ensure
              close_repair_client
            end
          end
        end

        def database_has_password_column(client)
          client.query('SHOW COLUMNS FROM mysql.user WHERE Field="Password"').size > 0
        end

        def redact_password(query, password)
          if password.nil? || password == ''
            query
          else
            query.gsub(password, 'REDACTED')
          end
        end
      end
    end
  end
end
