diff --git a/lib/puppet/provider/mysql.rb b/lib/puppet/provider/mysql.rb index 40d7b8d..2c572bb 100644 --- a/lib/puppet/provider/mysql.rb +++ b/lib/puppet/provider/mysql.rb @@ -1,177 +1,177 @@ # frozen_string_literal: true # Puppet provider for mysql class Puppet::Provider::Mysql < Puppet::Provider # Without initvars commands won't work. initvars # Make sure we find mysql commands on CentOS and FreeBSD ENV['PATH'] = ENV['PATH'] + ':/usr/libexec:/usr/local/libexec:/usr/local/bin' ENV['LD_LIBRARY_PATH'] = [ ENV['LD_LIBRARY_PATH'], '/usr/lib', '/usr/lib64', '/opt/rh/rh-mysql56/root/usr/lib', '/opt/rh/rh-mysql56/root/usr/lib64', '/opt/rh/rh-mysql57/root/usr/lib', '/opt/rh/rh-mysql57/root/usr/lib64', '/opt/rh/rh-mysql80/root/usr/lib', '/opt/rh/rh-mysql80/root/usr/lib64', '/opt/rh/rh-mariadb100/root/usr/lib', '/opt/rh/rh-mariadb100/root/usr/lib64', '/opt/rh/rh-mariadb101/root/usr/lib', '/opt/rh/rh-mariadb101/root/usr/lib64', '/opt/rh/rh-mariadb102/root/usr/lib', '/opt/rh/rh-mariadb102/root/usr/lib64', '/opt/rh/rh-mariadb103/root/usr/lib', '/opt/rh/rh-mariadb103/root/usr/lib64', '/opt/rh/mysql55/root/usr/lib', '/opt/rh/mysql55/root/usr/lib64', '/opt/rh/mariadb55/root/usr/lib', '/opt/rh/mariadb55/root/usr/lib64', '/usr/mysql/5.5/lib', '/usr/mysql/5.5/lib64', '/usr/mysql/5.6/lib', '/usr/mysql/5.6/lib64', '/usr/mysql/5.7/lib', '/usr/mysql/5.7/lib64', ].join(':') # rubocop:disable Style/HashSyntax commands :mysql_raw => 'mysql' commands :mysqld => 'mysqld' commands :mysqladmin => 'mysqladmin' # rubocop:enable Style/HashSyntax # Optional defaults file def self.defaults_file "--defaults-extra-file=#{Facter.value(:root_home)}/.my.cnf" if File.file?("#{Facter.value(:root_home)}/.my.cnf") end def self.mysqld_type # find the mysql "dialect" like mariadb / mysql etc. mysqld_version_string.scan(%r{mariadb}i) { return 'mariadb' } mysqld_version_string.scan(%r{\s\(percona}i) { return 'percona' } 'mysql' end def mysqld_type self.class.mysqld_type end def self.mysqld_version_string # As the possibility of the mysqld being remote we need to allow the version string to be overridden, # this can be done by facter.value as seen below. In the case that it has not been set and the facter # value is nil we use the mysql -v command to ensure we report the correct version of mysql for later use cases. @mysqld_version_string ||= Facter.value(:mysqld_version) || mysqld('-V') end def mysqld_version_string self.class.mysqld_version_string end def self.mysqld_version # NOTE: be prepared for '5.7.6-rc-log' etc results # versioncmp detects 5.7.6-log to be newer then 5.7.6 # this is why we need the trimming. mysqld_version_string&.scan(%r{\d+\.\d+\.\d+})&.first end def mysqld_version self.class.mysqld_version end def self.newer_than(forks_versions) forks_versions.key?(mysqld_type) && Puppet::Util::Package.versioncmp(mysqld_version, forks_versions[mysqld_type]) >= 0 end def newer_than(forks_versions) self.class.newer_than(forks_versions) end def self.older_than(forks_versions) forks_versions.key?(mysqld_type) && Puppet::Util::Package.versioncmp(mysqld_version, forks_versions[mysqld_type]) < 0 end def older_than(forks_versions) self.class.older_than(forks_versions) end def defaults_file self.class.defaults_file end def self.mysql_caller(text_of_sql, type) if type.eql? 'system' if File.file?("#{Facter.value(:root_home)}/.mylogin.cnf") ENV['MYSQL_TEST_LOGIN_FILE'] = "#{Facter.value(:root_home)}/.mylogin.cnf" mysql_raw([system_database, '-e', text_of_sql].flatten.compact).scrub else mysql_raw([defaults_file, system_database, '-e', text_of_sql].flatten.compact).scrub end elsif type.eql? 'regular' if File.file?("#{Facter.value(:root_home)}/.mylogin.cnf") ENV['MYSQL_TEST_LOGIN_FILE'] = "#{Facter.value(:root_home)}/.mylogin.cnf" mysql_raw(['-NBe', text_of_sql].flatten.compact).scrub else mysql_raw([defaults_file, '-NBe', text_of_sql].flatten.compact).scrub end else raise Puppet::Error, _("#mysql_caller: Unrecognised type '%{type}'" % { type: type }) end end def self.users mysql_caller("SELECT CONCAT(User, '@',Host) AS User FROM mysql.user", 'regular').split("\n") end # Optional parameter to run a statement on the MySQL system database. def self.system_database '--database=mysql' end def system_database self.class.system_database end # Take root@localhost and munge it to 'root'@'localhost' # Take root@id123@localhost and munge it to 'root@id123'@'localhost' def self.cmd_user(user) "'#{user.reverse.sub('@', "'@'").reverse}'" end # Take root.* and return ON `root`.* def self.cmd_table(table) table_string = '' # We can't escape *.* so special case this. - table_string << if table == '*.*' + table_string += if table == '*.*' '*.*' # Special case also for FUNCTIONs and PROCEDUREs elsif table.start_with?('FUNCTION ', 'PROCEDURE ') table.sub(%r{^(FUNCTION|PROCEDURE) (.*)(\..*)}, '\1 `\2`\3') else table.sub(%r{^(.*)(\..*)}, '`\1`\2') end table_string end def self.cmd_privs(privileges) return 'ALL PRIVILEGES' if privileges.include?('ALL') priv_string = '' privileges.each do |priv| - priv_string << "#{priv}, " + priv_string += "#{priv}, " end # Remove trailing , from the last element. priv_string.sub(%r{, $}, '') end # Take in potential options and build up a query string with them. def self.cmd_options(options) option_string = '' options.each do |opt| - option_string << ' WITH GRANT OPTION' if opt == 'GRANT' + option_string += ' WITH GRANT OPTION' if opt == 'GRANT' end option_string end end diff --git a/lib/puppet/provider/mysql_grant/mysql.rb b/lib/puppet/provider/mysql_grant/mysql.rb index 5d4947a..d26b0fb 100644 --- a/lib/puppet/provider/mysql_grant/mysql.rb +++ b/lib/puppet/provider/mysql_grant/mysql.rb @@ -1,178 +1,178 @@ # frozen_string_literal: true require File.expand_path(File.join(File.dirname(__FILE__), '..', 'mysql')) Puppet::Type.type(:mysql_grant).provide(:mysql, parent: Puppet::Provider::Mysql) do desc 'Set grants for users in MySQL.' commands mysql_raw: 'mysql' def self.instances instances = [] users.map do |user| user_string = cmd_user(user) query = "SHOW GRANTS FOR #{user_string};" begin grants = mysql_caller(query, 'regular') rescue Puppet::ExecutionFailure => e # Silently ignore users with no grants. Can happen e.g. if user is # defined with fqdn and server is run with skip-name-resolve. Example: # Default root user created by mysql_install_db on a host with fqdn # of myhost.mydomain.my: root@myhost.mydomain.my, when MySQL is started # with --skip-name-resolve. next if %r{There is no such grant defined for user}.match?(e.inspect) raise Puppet::Error, _('#mysql had an error -> %{inspect}') % { inspect: e.inspect } end # Once we have the list of grants generate entries for each. grants.each_line do |grant| # Match the munges we do in the type. munged_grant = grant.delete("'").delete('`').delete('"') # Matching: GRANT (SELECT, UPDATE) PRIVILEGES ON (*.*) TO ('root')@('127.0.0.1') (WITH GRANT OPTION) next unless match = munged_grant.match(%r{^GRANT\s(.+)\sON\s(.+)\sTO\s(.*)@(.*?)(\s.*)?$}) # rubocop:disable Lint/AssignmentInCondition privileges, table, user, host, rest = match.captures table.gsub!('\\\\', '\\') # split on ',' if it is not a non-'('-containing string followed by a # closing parenthesis ')'-char - e.g. only split comma separated elements not in # parentheses stripped_privileges = privileges.strip.split(%r{\s*,\s*(?![^(]*\))}).map do |priv| # split and sort the column_privileges in the parentheses and rejoin if priv.include?('(') type, col = priv.strip.split(%r{\s+|\b}, 2) type.upcase + ' (' + col.slice(1...-1).strip.split(%r{\s*,\s*}).sort.join(', ') + ')' else # Once we split privileges up on the , we need to make sure we # shortern ALL PRIVILEGES to just all. (priv == 'ALL PRIVILEGES') ? 'ALL' : priv.strip end end # Same here, but to remove OPTION leaving just GRANT. options = if %r{WITH\sGRANT\sOPTION}.match?(rest) ['GRANT'] else ['NONE'] end # fix double backslash that MySQL prints, so resources match table.gsub!('\\\\', '\\') # We need to return an array of instances so capture these instances << new( name: "#{user}@#{host}/#{table}", ensure: :present, privileges: stripped_privileges.sort, table: table, user: "#{user}@#{host}", options: options, ) end end instances end def self.prefetch(resources) users = instances resources.each_key do |name| if provider = users.find { |user| user.name == name } # rubocop:disable Lint/AssignmentInCondition resources[name].provider = provider end end end def grant(user, table, privileges, options) user_string = self.class.cmd_user(user) priv_string = self.class.cmd_privs(privileges) table_string = privileges.include?('PROXY') ? self.class.cmd_user(table) : self.class.cmd_table(table) query = "GRANT #{priv_string}" - query << " ON #{table_string}" - query << " TO #{user_string}" - query << self.class.cmd_options(options) unless options.nil? + query += " ON #{table_string}" + query += " TO #{user_string}" + query += self.class.cmd_options(options) unless options.nil? self.class.mysql_caller(query, 'system') end def create grant(@resource[:user], @resource[:table], @resource[:privileges], @resource[:options]) @property_hash[:ensure] = :present @property_hash[:table] = @resource[:table] @property_hash[:user] = @resource[:user] @property_hash[:options] = @resource[:options] if @resource[:options] @property_hash[:privileges] = @resource[:privileges] exists? ? (return true) : (return false) end def revoke(user, table, revoke_privileges = ['ALL']) user_string = self.class.cmd_user(user) table_string = revoke_privileges.include?('PROXY') ? self.class.cmd_user(table) : self.class.cmd_table(table) priv_string = self.class.cmd_privs(revoke_privileges) # revoke grant option needs to be a extra query, because # "REVOKE ALL PRIVILEGES, GRANT OPTION [..]" is only valid mysql syntax # if no ON clause is used. # It hast to be executed before "REVOKE ALL [..]" since a GRANT has to # exist to be executed successfully if revoke_privileges.include?('ALL') && !revoke_privileges.include?('PROXY') query = "REVOKE GRANT OPTION ON #{table_string} FROM #{user_string}" self.class.mysql_caller(query, 'system') end query = "REVOKE #{priv_string} ON #{table_string} FROM #{user_string}" self.class.mysql_caller(query, 'system') end def destroy # if the user was dropped, it'll have been removed from the user hash # as the grants are already removed by the DROP statement if self.class.users.include? @property_hash[:user] if @property_hash[:privileges].include?('PROXY') revoke(@property_hash[:user], @property_hash[:table], @property_hash[:privileges]) else revoke(@property_hash[:user], @property_hash[:table]) end end @property_hash.clear exists? ? (return false) : (return true) end def exists? @property_hash[:ensure] == :present || false end def flush @property_hash.clear self.class.mysql_caller('FLUSH PRIVILEGES', 'regular') end mk_resource_methods def diff_privileges(privileges_old, privileges_new) diff = { revoke: [], grant: [] } if privileges_old.include? 'ALL' diff[:revoke] = privileges_old diff[:grant] = privileges_new elsif privileges_new.include? 'ALL' diff[:grant] = privileges_new else diff[:revoke] = privileges_old - privileges_new diff[:grant] = privileges_new - privileges_old end diff end def privileges=(privileges) diff = diff_privileges(@property_hash[:privileges], privileges) unless diff[:revoke].empty? revoke(@property_hash[:user], @property_hash[:table], diff[:revoke]) end unless diff[:grant].empty? grant(@property_hash[:user], @property_hash[:table], diff[:grant], @property_hash[:options]) end @property_hash[:privileges] = privileges self.privileges end def options=(options) revoke(@property_hash[:user], @property_hash[:table]) grant(@property_hash[:user], @property_hash[:table], @property_hash[:privileges], options) @property_hash[:options] = options self.options end end diff --git a/lib/puppet/provider/mysql_login_path/inifile.rb b/lib/puppet/provider/mysql_login_path/inifile.rb index 005d6d5..dde4237 100644 --- a/lib/puppet/provider/mysql_login_path/inifile.rb +++ b/lib/puppet/provider/mysql_login_path/inifile.rb @@ -1,635 +1,643 @@ # encoding: UTF-8 # frozen_string_literal: true # See: https://github.com/puppetlabs/puppet/blob/main/lib/puppet/util/inifile.rb # This class represents the INI file and can be used to parse, modify, # and write INI files. class Puppet::Provider::MysqlLoginPath::IniFile < Puppet::Provider include Enumerable class Error < StandardError; end # VERSION = '3.0.0' # Public: Open an INI file and load the contents. # # filename - The name of the file as a String # opts - The Hash of options (default: {}) # :comment - String containing the comment character(s) # :parameter - String used to separate parameter and value # :encoding - Encoding String for reading / writing # :default - The String name of the default global section # # Examples # # IniFile.load('file.ini') # #=> IniFile instance # # IniFile.load('does/not/exist.ini') # #=> nil # # Returns an IniFile instance or nil if the file could not be opened. def self.load(filename, opts = {}) return unless File.file? filename new(opts.merge(filename: filename)) end # Get and set the filename attr_accessor :filename # Get and set the encoding attr_accessor :encoding # Public: Create a new INI file from the given set of options. If :content # is provided then it will be used to populate the INI file. If a :filename # is provided then the contents of the file will be parsed and stored in the # INI file. If neither the :content or :filename is provided then an empty # INI file is created. # # opts - The Hash of options (default: {}) # :content - The String/Hash containing the INI contents # :comment - String containing the comment character(s) # :parameter - String used to separate parameter and value # :encoding - Encoding String for reading / writing # :default - The String name of the default global section # :filename - The filename as a String # # Examples # # IniFile.new # #=> an empty IniFile instance # # IniFile.new( :content => "[global]\nfoo=bar" ) # #=> an IniFile instance # # IniFile.new( :filename => 'file.ini', :encoding => 'UTF-8' ) # #=> an IniFile instance # # IniFile.new( :content => "[global]\nfoo=bar", :comment => '#' ) # #=> an IniFile instance # def initialize(opts = {}) super @comment = opts.fetch(:comment, ';#') @param = opts.fetch(:parameter, '=') @encoding = opts.fetch(:encoding, nil) @default = opts.fetch(:default, 'global') @filename = opts.fetch(:filename, nil) content = opts.fetch(:content, nil) @ini = Hash.new { |h, k| h[k] = {} } if content.is_a?(Hash) then merge!(content) elsif content then parse(content) elsif @filename then read end end # Public: Write the contents of this IniFile to the file system. If left # unspecified, the currently configured filename and encoding will be used. # Otherwise the filename and encoding can be specified in the options hash. # # opts - The default options Hash # :filename - The filename as a String # :encoding - The encoding as a String # # Returns this IniFile instance. def write(opts = {}) filename = opts.fetch(:filename, @filename) encoding = opts.fetch(:encoding, @encoding) mode = encoding ? "w:#{encoding}" : 'w' File.open(filename, mode) do |f| @ini.each do |section, hash| f.puts "[#{section}]" hash.each { |param, val| f.puts "#{param} #{@param} #{escape_value val}" } f.puts end end self end alias save write # Public: Read the contents of the INI file from the file system and replace # and set the state of this IniFile instance. If left unspecified the # currently configured filename and encoding will be used when reading from # the file system. Otherwise the filename and encoding can be specified in # the options hash. # # opts - The default options Hash # :filename - The filename as a String # :encoding - The encoding as a String # # Returns this IniFile instance if the read was successful; nil is returned # if the file could not be read. def read(opts = {}) filename = opts.fetch(:filename, @filename) encoding = opts.fetch(:encoding, @encoding) return unless File.file? filename mode = encoding ? "r:#{encoding}" : 'r' File.open(filename, mode) { |fd| parse fd } self end alias restore read # Returns this IniFile converted to a String. def to_s s = [] @ini.each do |section, hash| s << "[#{section}]" hash.each { |param, val| s << "#{param} #{@param} #{escape_value val}" } s << '' end s.join("\n") end # Returns this IniFile converted to a Hash. def to_h @ini.dup end # Public: Creates a copy of this inifile with the entries from the # other_inifile merged into the copy. # # other - The other IniFile. # # Returns a new IniFile. def merge(other) dup.merge!(other) end # Public: Merges other_inifile into this inifile, overwriting existing # entries. Useful for having a system inifile with user overridable settings # elsewhere. # # other - The other IniFile. # # Returns this IniFile. def merge!(other) return self if other.nil? my_keys = @ini.keys other_keys = case other when IniFile other.instance_variable_get(:@ini).keys when Hash other.keys else raise Error, "cannot merge contents from '#{other.class.name}'" end (my_keys & other_keys).each do |key| case other[key] when Hash @ini[key].merge!(other[key]) when nil nil else raise Error, "cannot merge section #{key.inspect} - unsupported type: #{other[key].class.name}" end end (other_keys - my_keys).each do |key| @ini[key] = case other[key] when Hash other[key].dup when nil {} else raise Error, "cannot merge section #{key.inspect} - unsupported type: #{other[key].class.name}" end end self end # Public: Yield each INI file section, parameter, and value in turn to the # given block. # # block - The block that will be iterated by the each method. The block will # be passed the current section and the parameter/value pair. # # Examples # # inifile.each do |section, parameter, value| # puts "#{parameter} = #{value} [in section - #{section}]" # end # # Returns this IniFile. def each return unless block_given? @ini.each do |section, hash| hash.each do |param, val| yield section, param, val end end self end # Public: Yield each section in turn to the given block. # # block - The block that will be iterated by the each method. The block will # be passed the current section as a Hash. # # Examples # # inifile.each_section do |section| # puts section.inspect # end # # Returns this IniFile. def each_section return unless block_given? @ini.each_key { |section| yield section } self end # Public: Remove a section identified by name from the IniFile. # # section - The section name as a String. # # Returns the deleted section Hash. def delete_section(section) @ini.delete section.to_s end # Public: Get the section Hash by name. If the section does not exist, then # it will be created. # # section - The section name as a String. # # Examples # # inifile['global'] # #=> global section Hash # # Returns the Hash of parameter/value pairs for this section. def [](section) return nil if section.nil? @ini[section.to_s] end # Public: Set the section to a hash of parameter/value pairs. # # section - The section name as a String. # value - The Hash of parameter/value pairs. # # Examples # # inifile['tenderloin'] = { 'gritty' => 'yes' } # #=> { 'gritty' => 'yes' } # # Returns the value Hash. def []=(section, value) @ini[section.to_s] = value end # Public: Create a Hash containing only those INI file sections whose names # match the given regular expression. # # regex - The Regexp used to match section names. # # Examples # # inifile.match(/^tree_/) # #=> Hash of matching sections # # Return a Hash containing only those sections that match the given regular # expression. def match(regex) @ini.dup.delete_if { |section, _| section !~ regex } end # Public: Check to see if the IniFile contains the section. # # section - The section name as a String. # # Returns true if the section exists in the IniFile. def section?(section) @ini.key? section.to_s end # Returns an Array of section names contained in this IniFile. def sections @ini.keys end # Public: Freeze the state of this IniFile object. Any attempts to change # the object will raise an error. # # Returns this IniFile. def freeze super @ini.each_value { |h| h.freeze } @ini.freeze self end # Public: Mark this IniFile as tainted -- this will traverse each section # marking each as tainted. # # Returns this IniFile. def taint super @ini.each_value { |h| h.taint } @ini.taint self end # Public: Produces a duplicate of this IniFile. The duplicate is independent # of the original -- i.e. the duplicate can be modified without changing the # original. The tainted state of the original is copied to the duplicate. # # Returns a new IniFile. def dup other = super other.instance_variable_set(:@ini, Hash.new { |h, k| h[k] = {} }) @ini.each_pair { |s, h| other[s].merge! h } other.taint if tainted? other end # Public: Produces a duplicate of this IniFile. The duplicate is independent # of the original -- i.e. the duplicate can be modified without changing the # original. The tainted state and the frozen state of the original is copied # to the duplicate. # # Returns a new IniFile. def clone other = dup other.freeze if frozen? other end # Public: Compare this IniFile to some other IniFile. For two INI files to # be equivalent, they must have the same sections with the same parameter / # value pairs in each section. # # other - The other IniFile. # # Returns true if the INI files are equivalent and false if they differ. def eql?(other) return true if equal? other return false unless other.instance_of? self.class @ini == other.instance_variable_get(:@ini) end alias == eql? # Escape special characters. # # value - The String value to escape. # # Returns the escaped value. def escape_value(value) value = value.to_s.dup value.gsub!(%r{\\([0nrt])}, '\\\\\1') value.gsub!(%r{\n}, '\n') value.gsub!(%r{\r}, '\r') value.gsub!(%r{\t}, '\t') value.gsub!(%r{\0}, '\0') value end # Parse the given content and store the information in this IniFile # instance. All data will be cleared out and replaced with the information # read from the content. # # content - A String or a file descriptor (must respond to `each_line`) # # Returns this IniFile. def parse(content) parser = Parser.new(@ini, @param, @comment, @default) parser.parse(content) self end # The IniFile::Parser has the responsibility of reading the contents of an # .ini file and storing that information into a ruby Hash. The object being # parsed must respond to `each_line` - this includes Strings and any IO # object. class Parser attr_writer :section attr_accessor :property attr_accessor :value # Create a new IniFile::Parser that can be used to parse the contents of # an .ini file. # # hash - The Hash where parsed information will be stored # param - String used to separate parameter and value # comment - String containing the comment character(s) # default - The String name of the default global section # def initialize(hash, param, comment, default) @hash = hash @default = default comment = comment.to_s.empty? ? '\\z' : "\\s*(?:[#{comment}].*)?\\z" @section_regexp = %r{\A\s*\[([^\]]+)\]#{comment}} @ignore_regexp = %r{\A#{comment}} @property_regexp = %r{\A(.*?)(? true # "false" --> false # "" --> nil # "42" --> 42 # "3.14" --> 3.14 # "foo" --> "foo" # # Returns the typecast value. def typecast(value) case value when %r{\Atrue\z}i then true when %r{\Afalse\z}i then false when %r{\A\s*\z}i then nil else begin begin Integer(value) rescue Float(value) end rescue unescape_value(value) end end end # Unescape special characters found in the value string. This will convert # escaped null, tab, carriage return, newline, and backslash into their # literal equivalents. # # value - The String value to unescape. # # Returns the unescaped value. def unescape_value(value) value = value.to_s value.gsub!(%r{\\[0nrt\\]}) do |char| case char when '\0' then "\0" when '\n' then "\n" when '\r' then "\r" when '\t' then "\t" when '\\\\' then '\\' end end value end end end # IniFile diff --git a/lib/puppet/provider/mysql_user/mysql.rb b/lib/puppet/provider/mysql_user/mysql.rb index 91c1705..30d0dd2 100644 --- a/lib/puppet/provider/mysql_user/mysql.rb +++ b/lib/puppet/provider/mysql_user/mysql.rb @@ -1,257 +1,257 @@ # frozen_string_literal: true require File.expand_path(File.join(File.dirname(__FILE__), '..', 'mysql')) Puppet::Type.type(:mysql_user).provide(:mysql, parent: Puppet::Provider::Mysql) do desc 'manage users for a mysql database.' commands mysql_raw: 'mysql' # Build a property_hash containing all the discovered information about MySQL # users. def self.instances users = mysql_caller("SELECT CONCAT(User, '@',Host) AS User FROM mysql.user", 'regular').split("\n") # To reduce the number of calls to MySQL we collect all the properties in # one big swoop. users.map do |name| if mysqld_version.nil? ## Default ... # rubocop:disable Layout/LineLength query = "SELECT MAX_USER_CONNECTIONS, MAX_CONNECTIONS, MAX_QUESTIONS, MAX_UPDATES, SSL_TYPE, SSL_CIPHER, X509_ISSUER, X509_SUBJECT, PASSWORD /*!50508 , PLUGIN */ FROM mysql.user WHERE CONCAT(user, '@', host) = '#{name}'" elsif newer_than('mysql' => '5.7.6', 'percona' => '5.7.6') query = "SELECT MAX_USER_CONNECTIONS, MAX_CONNECTIONS, MAX_QUESTIONS, MAX_UPDATES, SSL_TYPE, SSL_CIPHER, X509_ISSUER, X509_SUBJECT, AUTHENTICATION_STRING, PLUGIN FROM mysql.user WHERE CONCAT(user, '@', host) = '#{name}'" elsif newer_than('mariadb' => '10.1.21') query = "SELECT MAX_USER_CONNECTIONS, MAX_CONNECTIONS, MAX_QUESTIONS, MAX_UPDATES, SSL_TYPE, SSL_CIPHER, X509_ISSUER, X509_SUBJECT, PASSWORD, PLUGIN, AUTHENTICATION_STRING FROM mysql.user WHERE CONCAT(user, '@', host) = '#{name}'" else query = "SELECT MAX_USER_CONNECTIONS, MAX_CONNECTIONS, MAX_QUESTIONS, MAX_UPDATES, SSL_TYPE, SSL_CIPHER, X509_ISSUER, X509_SUBJECT, PASSWORD /*!50508 , PLUGIN */ FROM mysql.user WHERE CONCAT(user, '@', host) = '#{name}'" end @max_user_connections, @max_connections_per_hour, @max_queries_per_hour, @max_updates_per_hour, ssl_type, ssl_cipher, x509_issuer, x509_subject, @password, @plugin, @authentication_string = mysql_caller(query, 'regular').chomp.split(%r{\t}) @tls_options = parse_tls_options(ssl_type, ssl_cipher, x509_issuer, x509_subject) if newer_than('mariadb' => '10.1.21') && @plugin == 'ed25519' # Some auth plugins (e.g. ed25519) use authentication_string # to store password hash or auth information @password = @authentication_string elsif (newer_than('mariadb' => '10.2.16') && older_than('mariadb' => '10.2.19')) || (newer_than('mariadb' => '10.3.8') && older_than('mariadb' => '10.3.11')) # Old mariadb 10.2 or 10.3 store password hash in authentication_string # https://jira.mariadb.org/browse/MDEV-16238 https://jira.mariadb.org/browse/MDEV-16774 @password = @authentication_string end # rubocop:enable Layout/LineLength new(name: name, ensure: :present, password_hash: @password, plugin: @plugin, max_user_connections: @max_user_connections, max_connections_per_hour: @max_connections_per_hour, max_queries_per_hour: @max_queries_per_hour, max_updates_per_hour: @max_updates_per_hour, tls_options: @tls_options) end end # We iterate over each mysql_user entry in the catalog and compare it against # the contents of the property_hash generated by self.instances def self.prefetch(resources) users = instances # rubocop:disable Lint/AssignmentInCondition resources.each_key do |name| if provider = users.find { |user| user.name == name } resources[name].provider = provider end end # rubocop:enable Lint/AssignmentInCondition end def create # (MODULES-3539) Allow @ in username merged_name = @resource[:name].reverse.sub('@', "'@'").reverse password_hash = @resource.value(:password_hash) plugin = @resource.value(:plugin) max_user_connections = @resource.value(:max_user_connections) || 0 max_connections_per_hour = @resource.value(:max_connections_per_hour) || 0 max_queries_per_hour = @resource.value(:max_queries_per_hour) || 0 max_updates_per_hour = @resource.value(:max_updates_per_hour) || 0 tls_options = @resource.value(:tls_options) || ['NONE'] # Use CREATE USER to be compatible with NO_AUTO_CREATE_USER sql_mode # This is also required if you want to specify a authentication plugin if !plugin.nil? if !password_hash.nil? self.class.mysql_caller("CREATE USER '#{merged_name}' IDENTIFIED WITH '#{plugin}' AS '#{password_hash}'", 'system') else self.class.mysql_caller("CREATE USER '#{merged_name}' IDENTIFIED WITH '#{plugin}'", 'system') end @property_hash[:ensure] = :present @property_hash[:plugin] = plugin elsif newer_than('mysql' => '5.7.6', 'percona' => '5.7.6', 'mariadb' => '10.1.3') self.class.mysql_caller("CREATE USER IF NOT EXISTS '#{merged_name}' IDENTIFIED WITH 'mysql_native_password' AS '#{password_hash}'", 'system') @property_hash[:ensure] = :present @property_hash[:password_hash] = password_hash else self.class.mysql_caller("CREATE USER '#{merged_name}' IDENTIFIED BY PASSWORD '#{password_hash}'", 'system') @property_hash[:ensure] = :present @property_hash[:password_hash] = password_hash end # rubocop:disable Layout/LineLength if newer_than('mysql' => '5.7.6', 'percona' => '5.7.6') self.class.mysql_caller("ALTER USER IF EXISTS '#{merged_name}' WITH MAX_USER_CONNECTIONS #{max_user_connections} MAX_CONNECTIONS_PER_HOUR #{max_connections_per_hour} MAX_QUERIES_PER_HOUR #{max_queries_per_hour} MAX_UPDATES_PER_HOUR #{max_updates_per_hour}", 'system') else self.class.mysql_caller("GRANT USAGE ON *.* TO '#{merged_name}' WITH MAX_USER_CONNECTIONS #{max_user_connections} MAX_CONNECTIONS_PER_HOUR #{max_connections_per_hour} MAX_QUERIES_PER_HOUR #{max_queries_per_hour} MAX_UPDATES_PER_HOUR #{max_updates_per_hour}", 'system') end # rubocop:enable Layout/LineLength @property_hash[:max_user_connections] = max_user_connections @property_hash[:max_connections_per_hour] = max_connections_per_hour @property_hash[:max_queries_per_hour] = max_queries_per_hour @property_hash[:max_updates_per_hour] = max_updates_per_hour merged_tls_options = tls_options.join(' AND ') if newer_than('mysql' => '5.7.6', 'percona' => '5.7.6', 'mariadb' => '10.2.0') self.class.mysql_caller("ALTER USER '#{merged_name}' REQUIRE #{merged_tls_options}", 'system') else self.class.mysql_caller("GRANT USAGE ON *.* TO '#{merged_name}' REQUIRE #{merged_tls_options}", 'system') end @property_hash[:tls_options] = tls_options exists? ? (return true) : (return false) end def destroy # (MODULES-3539) Allow @ in username merged_name = @resource[:name].reverse.sub('@', "'@'").reverse if_exists = if newer_than('mysql' => '5.7', 'percona' => '5.7', 'mariadb' => '10.1.3') 'IF EXISTS ' else '' end self.class.mysql_caller("DROP USER #{if_exists}'#{merged_name}'", 'system') @property_hash.clear exists? ? (return false) : (return true) end def exists? @property_hash[:ensure] == :present || false end ## ## MySQL user properties ## # Generates method for all properties of the property_hash mk_resource_methods def password_hash=(string) merged_name = self.class.cmd_user(@resource[:name]) plugin = @resource.value(:plugin) # We have a fact for the mysql version ... if mysqld_version.nil? # default ... if mysqld_version does not work self.class.mysql_caller("SET PASSWORD FOR #{merged_name} = '#{string}'", 'system') elsif newer_than('mariadb' => '10.1.21') && plugin == 'ed25519' raise ArgumentError, _('ed25519 hash should be 43 bytes long.') unless string.length == 43 # ALTER USER statement is only available upstream starting 10.2 # https://mariadb.com/kb/en/mariadb-1020-release-notes/ if newer_than('mariadb' => '10.2.0') sql = "ALTER USER #{merged_name} IDENTIFIED WITH ed25519 AS '#{string}'" else concat_name = @resource[:name] sql = "UPDATE mysql.user SET password = '', plugin = 'ed25519'" - sql << ", authentication_string = '#{string}'" - sql << " where CONCAT(user, '@', host) = '#{concat_name}'; FLUSH PRIVILEGES" + sql += ", authentication_string = '#{string}'" + sql += " where CONCAT(user, '@', host) = '#{concat_name}'; FLUSH PRIVILEGES" end self.class.mysql_caller(sql, 'system') elsif newer_than('mysql' => '5.7.6', 'percona' => '5.7.6', 'mariadb' => '10.2.0') raise ArgumentError, _('Only mysql_native_password (*ABCD...XXX) hashes are supported.') unless %r{^\*|^$}.match?(string) self.class.mysql_caller("ALTER USER #{merged_name} IDENTIFIED WITH mysql_native_password AS '#{string}'", 'system') else self.class.mysql_caller("SET PASSWORD FOR #{merged_name} = '#{string}'", 'system') end (password_hash == string) ? (return true) : (return false) end def max_user_connections=(int) merged_name = self.class.cmd_user(@resource[:name]) self.class.mysql_caller("GRANT USAGE ON *.* TO #{merged_name} WITH MAX_USER_CONNECTIONS #{int}", 'system').chomp (max_user_connections == int) ? (return true) : (return false) end def max_connections_per_hour=(int) merged_name = self.class.cmd_user(@resource[:name]) self.class.mysql_caller("GRANT USAGE ON *.* TO #{merged_name} WITH MAX_CONNECTIONS_PER_HOUR #{int}", 'system').chomp (max_connections_per_hour == int) ? (return true) : (return false) end def max_queries_per_hour=(int) merged_name = self.class.cmd_user(@resource[:name]) self.class.mysql_caller("GRANT USAGE ON *.* TO #{merged_name} WITH MAX_QUERIES_PER_HOUR #{int}", 'system').chomp (max_queries_per_hour == int) ? (return true) : (return false) end def max_updates_per_hour=(int) merged_name = self.class.cmd_user(@resource[:name]) self.class.mysql_caller("GRANT USAGE ON *.* TO #{merged_name} WITH MAX_UPDATES_PER_HOUR #{int}", 'system').chomp (max_updates_per_hour == int) ? (return true) : (return false) end def plugin=(string) merged_name = self.class.cmd_user(@resource[:name]) if newer_than('mariadb' => '10.1.21') && string == 'ed25519' if newer_than('mariadb' => '10.2.0') sql = "ALTER USER #{merged_name} IDENTIFIED WITH '#{string}' AS '#{@resource[:password_hash]}'" else concat_name = @resource[:name] sql = "UPDATE mysql.user SET password = '', plugin = '#{string}'" - sql << ", authentication_string = '#{@resource[:password_hash]}'" - sql << " where CONCAT(user, '@', host) = '#{concat_name}'; FLUSH PRIVILEGES" + sql += ", authentication_string = '#{@resource[:password_hash]}'" + sql += " where CONCAT(user, '@', host) = '#{concat_name}'; FLUSH PRIVILEGES" end elsif newer_than('mysql' => '5.7.6', 'percona' => '5.7.6', 'mariadb' => '10.2.0') sql = "ALTER USER #{merged_name} IDENTIFIED WITH '#{string}'" - sql << " AS '#{@resource[:password_hash]}'" if string == 'mysql_native_password' + sql += " AS '#{@resource[:password_hash]}'" if string == 'mysql_native_password' else # See https://bugs.mysql.com/bug.php?id=67449 sql = "UPDATE mysql.user SET plugin = '#{string}'" - sql << ((string == 'mysql_native_password') ? ", password = '#{@resource[:password_hash]}'" : ", password = ''") - sql << " WHERE CONCAT(user, '@', host) = '#{@resource[:name]}'" + sql += ((string == 'mysql_native_password') ? ", password = '#{@resource[:password_hash]}'" : ", password = ''") + sql += " WHERE CONCAT(user, '@', host) = '#{@resource[:name]}'" end self.class.mysql_caller(sql, 'system') (plugin == string) ? (return true) : (return false) end def tls_options=(array) merged_name = self.class.cmd_user(@resource[:name]) merged_tls_options = array.join(' AND ') if newer_than('mysql' => '5.7.6', 'percona' => '5.7.6', 'mariadb' => '10.2.0') self.class.mysql_caller("ALTER USER #{merged_name} REQUIRE #{merged_tls_options}", 'system') else self.class.mysql_caller("GRANT USAGE ON *.* TO #{merged_name} REQUIRE #{merged_tls_options}", 'system') end (tls_options == array) ? (return true) : (return false) end def self.parse_tls_options(ssl_type, ssl_cipher, x509_issuer, x509_subject) if ssl_type == 'ANY' ['SSL'] elsif ssl_type == 'X509' ['X509'] elsif ssl_type == 'SPECIFIED' options = [] options << "CIPHER '#{ssl_cipher}'" if !ssl_cipher.nil? && !ssl_cipher.empty? options << "ISSUER '#{x509_issuer}'" if !x509_issuer.nil? && !x509_issuer.empty? options << "SUBJECT '#{x509_subject}'" if !x509_subject.nil? && !x509_subject.empty? options else ['NONE'] end end end