diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5d174fa..3d608c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,9 +27,12 @@ jobs: ruby: - 3.0.6 - 3.1.4 + - 3.4.1 mysql: - - 5.6 - - 5.7 + # TODO: skip old env + # - 5.6 + # - 5.7 + - 8.0.28 needs: - check_test_execution_conditions services: @@ -50,6 +53,29 @@ jobs: with: ruby-version: ${{ matrix.ruby }} bundler-cache: true + - name: Setup MySQL variables + run: | + version=`echo ${{ matrix.mysql }} | awk -F. '{printf "%d%03d", $1, $2}'` + # 8.0 or later + if (( 8000 <= version )); then + # https://blog.n-z.jp/blog/2021-02-20-github-actions-services-mysqld-option.html + touch test.cnf + ( + echo '[mysqld]'; + echo 'default_authentication_plugin=mysql_native_password'; + echo 'log_bin_trust_function_creators=ON'; + ) > test.cnf + docker cp ./test.cnf ${{ job.services.mysql.id }}:/etc/mysql/conf.d/ + docker restart ${{ job.services.mysql.id }} + for sleep in 0 ${WAITS:- 1 2 4 8 15 25 100}; do + sleep "$sleep" + health_status=`docker inspect --format="{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}" ${{ job.services.mysql.id }}` + if [ 'starting' != "$health_status" ]; then + exit 0 + fi + done + exit 1 + fi - name: Run RSpec for ${{ matrix.mysql }} run: bundle exec rake spec`echo ${{ matrix.mysql }} | awk -F. '{print $1"_"$2}'` env: diff --git a/Rakefile b/Rakefile index 1066d01..5b24995 100644 --- a/Rakefile +++ b/Rakefile @@ -4,7 +4,7 @@ require 'rspec/core/rake_task' RSpec::Core::RakeTask.new('spec') task :default => :spec_all -suffixes = %w(5_6 5_7) +suffixes = %w(5_6 5_7 8_0) task :spec_all => suffixes.map { |s| "spec#{s}" } suffixes.each do |suffix| @@ -13,7 +13,8 @@ suffixes.each do |suffix| if overwrite_host ENV['MYSQL_HOST'] = "mysql#{suffix}" end - ENV['MYSQL5_7'] = (suffix == '5_7' ? 1 : 0).to_s + ENV['MYSQL5_7'] = (%w(5_7 8_0).include?(suffix) ? 1 : 0).to_s + ENV['MYSQL8_0'] = (%w(8_0).include?(suffix) ? 1 : 0).to_s Rake::Task['spec'].execute end end diff --git a/docker-compose.yml b/docker-compose.yml index 007b046..29fffca 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -19,6 +19,17 @@ services: timeout: 5s interval: 5s retries: 5 + mysql8_0: + image: "mysql:8.0.28" + environment: + MYSQL_ALLOW_EMPTY_PASSWORD: "yes" + command: --default-authentication-plugin=mysql_native_password --log-bin-trust-function-creators=ON + healthcheck: + test: ["CMD", "mysql", "-h", "localhost", "-u", "root", "--execute", "SHOW DATABASES;"] + start_period: 30s + timeout: 5s + interval: 5s + retries: 5 rake: build: . depends_on: @@ -26,6 +37,8 @@ services: condition: service_healthy mysql5_7: condition: service_healthy + mysql8_0: + condition: service_healthy entrypoint: ["rake"] volumes: - .:/usr/src/app diff --git a/gratan.gemspec b/gratan.gemspec index 227f1a7..cde87d1 100644 --- a/gratan.gemspec +++ b/gratan.gemspec @@ -22,8 +22,9 @@ Gem::Specification.new do |spec| spec.add_dependency 'term-ansicolor' spec.add_dependency 'deep_merge' spec.add_dependency 'hashie' + spec.add_dependency 'csv' spec.add_development_dependency 'bundler' - spec.add_development_dependency 'rake', '~> 10.0' + spec.add_development_dependency 'rake', '~> 12.3.2' spec.add_development_dependency 'rspec', '>= 3.0.0' spec.add_development_dependency 'timecop' end diff --git a/lib/gratan/client.rb b/lib/gratan/client.rb index a85be8d..67c0113 100644 --- a/lib/gratan/client.rb +++ b/lib/gratan/client.rb @@ -214,7 +214,7 @@ def normalize_privs(privs) priv[0].upcase! if priv[1] - priv[1] = priv[1].split(',').map {|i| i.gsub(')', '').strip }.sort.join(', ') + priv[1] = priv[1].split(',').map { |i| i.gsub(/[`')]/, '').strip }.sort.join(', ') priv[1] << ')' end diff --git a/lib/gratan/driver.rb b/lib/gratan/driver.rb index 9e24154..5d00de4 100644 --- a/lib/gratan/driver.rb +++ b/lib/gratan/driver.rb @@ -63,19 +63,36 @@ def flush_privileges def create_user(user, host, options = {}) objects = options[:objects] + identified = options[:options][:identified] + required = options[:required] + with_option = options[:with] + auth_plugin = options[:auth_plugin] || "mysql_native_password" grant_options = options[:options] - granted = false + required ||= grant_options.delete(:required) + granted = objects.keys.any? do |object_or_regexp| + !expand_object(object_or_regexp).empty? + end + + unless granted + log(:warn, "there was no privileges to grant to #{quote_user(user, host)}", :color => :yellow) + return + end + + + sql = "CREATE USER #{quote_user(user, host)}" + if identified + prepos, quoted_identifier = quote_identifier_with_preposition(identified, options) + sql << " IDENTIFIED WITH #{auth_plugin} #{prepos} #{quoted_identifier}" + end + sql << " REQUIRE #{required}" if required + sql << " WITH #{with_option}" if with_option + update(sql) objects.each do |object_or_regexp, object_options| expand_object(object_or_regexp).each do |object| grant(user, host, object, grant_options.merge(object_options)) - granted = true end end - - unless granted - log(:warn, "there was no privileges to grant to #{quote_user(user, host)}", :color => :yellow) - end end def drop_user(user, host) @@ -85,7 +102,6 @@ def drop_user(user, host) def grant(user, host, object, options) privs = options.fetch(:privs) - identified = options[:identified] required = options[:required] with_option = options[:with] @@ -95,7 +111,6 @@ def grant(user, host, object, options) quote_user(user, host), ] - sql << " IDENTIFIED BY #{quote_identifier(identified)}" if identified sql << " REQUIRE #{required}" if required sql << " WITH #{with_option}" if with_option @@ -110,29 +125,27 @@ def grant(user, host, object, options) end end - def identify(user, host, identifier) - sql = 'GRANT USAGE ON *.* TO %s IDENTIFIED BY %s' % [ + def identify(user, host, identifier, auth_plugin = "mysql_native_password") + prepos, quoted_identifier = quote_identifier_with_preposition(identifier) + + sql = "ALTER USER %s IDENTIFIED WITH #{auth_plugin} #{prepos} %s" % [ quote_user(user, host), - quote_identifier(identifier), + quoted_identifier, ] update(sql) if (identifier || '').empty? - set_password(user, host, identifier) + set_password(user, host, identifier, auth_plugin) end end - def set_password(user, host, password, options = {}) - password ||= '' - - unless options[:hash] - password = "PASSWORD('#{escape(password)}')" - end + def set_password(user, host, password, auth_plugin = 'mysql_native_password', options = {}) + prepos, quoted_identifier = quote_identifier_with_preposition(password, options) - sql = 'SET PASSWORD FOR %s = %s' % [ + sql = "ALTER USER %s IDENTIFIED WITH #{auth_plugin} #{prepos} %s" % [ quote_user(user, host), - password, + quoted_identifier, ] update(sql) @@ -141,7 +154,7 @@ def set_password(user, host, password, options = {}) def set_require(user, host, required) required ||= 'NONE' - sql = 'GRANT USAGE ON *.* TO %s REQUIRE %s' % [ + sql = 'ALTER USER %s REQUIRE %s' % [ quote_user(user, host), required ] @@ -166,7 +179,7 @@ def revoke(user, host, object, options = {}) def revoke0(user, host, object, privs) sql = 'REVOKE %s ON %s FROM %s' % [ - privs.join(', '), + privs.join(', ').gsub('\'', '').strip, quote_object(object), quote_user(user, host), ] @@ -175,13 +188,20 @@ def revoke0(user, host, object, privs) end def update_with_option(user, host, object, with_option) - options = [] + update_with_grant_option(user, host, object, with_option) + update_with_resource_option(user, host, with_option) + end + def update_with_grant_option(user, host, object, with_option) if with_option =~ /\bGRANT\s+OPTION\b/i - options << 'GRANT OPTION' + grant(user, host, object, :privs => ['USAGE'], :with => 'GRANT OPTION') else revoke(user, host, object, :privs => ['GRANT OPTION']) end + end + + def update_with_resource_option(user, host, with_option) + options = [] %w( MAX_QUERIES_PER_HOUR @@ -199,10 +219,16 @@ def update_with_option(user, host, object, with_option) end unless options.empty? - grant(user, host, object, :privs => ['USAGE'], :with => options.join(' ')) + alter_user_with(user, host, with: options.join(' ')) end end + def alter_user_with(user, host, with:) + sql = "ALTER USER #{quote_user(user, host)}" + sql << " WITH #{with}" if with + update(sql) + end + def disable_log_bin_local unless @options[:skip_disable_log_bin] query('SET SQL_LOG_BIN = 0') @@ -257,6 +283,27 @@ def quote_object(object) object_type + object.split('.', 2).map {|i| i == '*' ? i : "`#{i}`" }.join('.') end + def quote_identifier_with_preposition(identifier, options = {}) + identifier = if identifier + identifier.dup + else + '' + end + prepos = 'BY' + quoted_identifier = quote_identifier(identifier) + if options[:hash] + prepos = 'AS' + password = query("SELECT CONCAT('*', UPPER(SHA1(UNHEX(SHA1(#{quoted_identifier}))))) AS PASSWORD").first.values.first + elsif identifier.slice!(/^\s*PASSWORD\s+/i) + prepos = 'AS' + quoted_identifier = identifier + else + quoted_identifier = quote_identifier(identifier) + end + + [prepos, quoted_identifier] + end + def quote_identifier(identifier) identifier ||= '' diff --git a/lib/gratan/exporter.rb b/lib/gratan/exporter.rb index 97d92b1..c391a28 100644 --- a/lib/gratan/exporter.rb +++ b/lib/gratan/exporter.rb @@ -23,7 +23,9 @@ def export end @driver.show_grants(user, host) do |stmt| - grants << Gratan::GrantParser.parse(stmt, create_user) + grant = Gratan::GrantParser.parse(stmt, create_user) + next if grant.fetch(:privs).empty? + grants << grant end end diff --git a/lib/gratan/grant_parser.rb b/lib/gratan/grant_parser.rb index e6a6f6a..a9aaf7b 100644 --- a/lib/gratan/grant_parser.rb +++ b/lib/gratan/grant_parser.rb @@ -1,4 +1,50 @@ class Gratan::GrantParser + # https://dev.mysql.com/doc/refman/8.0/ja/privileges-provided.html#privileges-provided-summary + STATIC_PRIVS = [ + 'ALL', + 'ALL PRIVILEGES', + 'ALTER', + 'ALTER ROUTINE', + 'CREATE', + 'CREATE ROLE', + 'CREATE ROUTINE', + 'CREATE TABLESPACE', + 'CREATE TEMPORARY TABLES', + 'CREATE USER', + 'CREATE VIEW', + 'DELETE', + 'DROP', + 'DROP ROLE', + 'EVENT', + 'EXECUTE', + 'FILE', + 'GRANT OPTION', + 'INDEX', + 'INSERT', + 'LOCK TABLES', + 'PROCESS', + 'PROXY', + 'REFERENCES', + 'RELOAD', + 'REPLICATION CLIENT', + 'REPLICATION SLAVE', + 'SELECT', + 'SHOW DATABASES', + 'SHOW VIEW', + 'SHUTDOWN', + 'SUPER', + 'TRIGGER', + 'UPDATE', + 'USAGE', + ] + + CAN_USE_WITH_COLUMN_LIST_PRIVS = [ + 'INSERT', + 'REFERENCES', + 'SELECT', + 'UPDATE', + ] + def initialize(stmt, create_user = nil) @stmt = stmt.strip @create_user = create_user @@ -34,7 +80,7 @@ def parse_require required = $1 if @create_user - @create_user.slice!(/\s+REQUIRE\s+(\S+(?:\s+'[^']+')?)(?:\s+WITH\s+(.+))?\s+PASSWORD\s+.+\z/) + @create_user.slice!(/\s+REQUIRE\s+(\S+(?:\s+'[^']+')?)(?:\s+WITH\s+(.+?))?\s+PASSWORD\s+.+\z/) required = $1 resource_option = $2 @@ -66,7 +112,7 @@ def parse_identified end def parse_main - md = /\AGRANT\s+(.+?)\s+ON\s+(.+?)\s+TO\s+'(.*)'@'(.+)'\z/.match(@stmt) + md = /\AGRANT\s+(.+?)\s+ON\s+(.+?)\s+TO\s+['`](.*)['`]@['`](.+)['`]\z/.match(@stmt) privs, object, user, host = md.captures @parsed[:privs] = parse_privs(privs.strip) @parsed[:object] = object.gsub('`', '').strip @@ -82,6 +128,24 @@ def parse_privs(privs) priv_list << priv.strip.sub(/,\z/, '').strip end - priv_list + priv_list.select(&method(:static_priv?)) + end + + def static_priv?(priv) + @static_priv_regexp ||= Regexp.new( + "\\A#{ + Regexp.union( + STATIC_PRIVS.map { |priv| priv.gsub(/\s+/, '\s+') }.map { |re| Regexp.new(re) } + ) + }\\z" + ) + @can_use_with_column_list_privs_regexp ||= Regexp.new( + "\\A#{ + Regexp.union( + CAN_USE_WITH_COLUMN_LIST_PRIVS.map { |priv| Regexp.new(priv) } + ) + }\\s+\\(" + ) + @static_priv_regexp.match(priv) || @can_use_with_column_list_privs_regexp.match(priv) end end diff --git a/spec/change/change_grants_2_spec.rb b/spec/change/change_grants_2_spec.rb index 2ae3e2a..6a47fd8 100644 --- a/spec/change/change_grants_2_spec.rb +++ b/spec/change/change_grants_2_spec.rb @@ -17,6 +17,23 @@ } end + def expect_initial_state + expect(show_create_users).to match_array [ + start_with("CREATE USER `bob`@`localhost` IDENTIFIED WITH 'mysql_native_password' REQUIRE NONE PASSWORD EXPIRE"), + start_with("CREATE USER `scott`@`localhost` IDENTIFIED WITH 'mysql_native_password' AS '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL PASSWORD EXPIRE"), + ] + expect(show_grants).to match_array [ + *grant_all_priv(user: 'bob', host: 'localhost', with: 'GRANT OPTION'), + "GRANT USAGE ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL", + ].normalize + end + + context 'when before subject' do + it do + expect_initial_state + end + end + context 'when update password' do subject { client } @@ -37,8 +54,12 @@ RUBY } + expect(show_create_users).to match_array [ + start_with("CREATE USER `bob`@`localhost` IDENTIFIED WITH 'mysql_native_password' AS '*531E182E2F72080AB0740FE2F2D689DBE0146E04' REQUIRE NONE PASSWORD EXPIRE"), + start_with("CREATE USER `scott`@`localhost` IDENTIFIED WITH 'mysql_native_password' AS '*23AE809DDACAF96AF0FD78ED04B6A265E05AA257' REQUIRE SSL PASSWORD EXPIRE"), + ] expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON *.* TO 'bob'@'localhost' IDENTIFIED BY PASSWORD '*531E182E2F72080AB0740FE2F2D689DBE0146E04' WITH GRANT OPTION", + *grant_all_priv(user: 'bob', host: 'localhost', auth: "IDENTIFIED BY PASSWORD '*531E182E2F72080AB0740FE2F2D689DBE0146E04'", with: 'GRANT OPTION'), "GRANT USAGE ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*23AE809DDACAF96AF0FD78ED04B6A265E05AA257' REQUIRE SSL", ].normalize end @@ -64,8 +85,12 @@ RUBY } + expect(show_create_users).to match_array [ + start_with("CREATE USER `bob`@`localhost` IDENTIFIED WITH 'mysql_native_password' REQUIRE NONE PASSWORD EXPIRE "), + start_with("CREATE USER `scott`@`localhost` IDENTIFIED WITH 'mysql_native_password' REQUIRE SSL PASSWORD EXPIRE"), + ] expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON *.* TO 'bob'@'localhost' WITH GRANT OPTION", + *grant_all_priv(user: 'bob', host: 'localhost', with: 'GRANT OPTION'), "GRANT USAGE ON *.* TO 'scott'@'localhost' REQUIRE SSL", ].normalize end @@ -91,10 +116,7 @@ RUBY } - expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON *.* TO 'bob'@'localhost' WITH GRANT OPTION", - "GRANT USAGE ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL", - ].normalize + expect_initial_state end end @@ -118,8 +140,12 @@ RUBY } + expect(show_create_users).to match_array [ + start_with("CREATE USER `bob`@`localhost` IDENTIFIED WITH 'mysql_native_password' REQUIRE SSL PASSWORD EXPIRE"), + start_with("CREATE USER `scott`@`localhost` IDENTIFIED WITH 'mysql_native_password' AS '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE X509 PASSWORD EXPIRE"), + ] expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON *.* TO 'bob'@'localhost' REQUIRE SSL WITH GRANT OPTION", + *grant_all_priv(user: 'bob', host: 'localhost', required: 'SSL', with: 'GRANT OPTION'), "GRANT USAGE ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE X509", ].normalize end @@ -145,10 +171,14 @@ RUBY } + expect(show_create_users).to match_array [ + start_with("CREATE USER `bob`@`localhost` IDENTIFIED WITH 'mysql_native_password' REQUIRE NONE PASSWORD"), + start_with("CREATE USER `scott`@`localhost` IDENTIFIED WITH 'mysql_native_password' AS '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL WITH MAX_QUERIES_PER_HOUR 1 MAX_UPDATES_PER_HOUR 2 MAX_CONNECTIONS_PER_HOUR 3 MAX_USER_CONNECTIONS 4 PASSWORD"), + ] expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON *.* TO 'bob'@'localhost'", - "GRANT USAGE ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL WITH GRANT OPTION MAX_QUERIES_PER_HOUR 1 MAX_UPDATES_PER_HOUR 2 MAX_CONNECTIONS_PER_HOUR 3 MAX_USER_CONNECTIONS 4", - ].normalize + *grant_all_priv(user: 'bob', host: 'localhost'), + "GRANT USAGE ON *.* TO 'scott'@'localhost' WITH GRANT OPTION", + ].map { |str| str.gsub(/'/, '`') } end end end diff --git a/spec/change/change_grants_3_spec.rb b/spec/change/change_grants_3_spec.rb index 043bd2c..e05d0f9 100644 --- a/spec/change/change_grants_3_spec.rb +++ b/spec/change/change_grants_3_spec.rb @@ -31,6 +31,26 @@ } end + def expect_initial_state + expect(show_create_users).to match_array [ + start_with("CREATE USER `bob`@`localhost` IDENTIFIED WITH 'mysql_native_password' REQUIRE NONE PASSWORD EXPIRE"), + start_with("CREATE USER `scott`@`localhost` IDENTIFIED WITH 'mysql_native_password' AS '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL PASSWORD EXPIRE"), + ] + expect(show_grants).to match_array [ + "GRANT ALL PRIVILEGES ON `test`.* TO 'bob'@'localhost'", + "GRANT SELECT (user) ON `mysql`.`user` TO 'scott'@'localhost'", + "GRANT SELECT, INSERT ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL", + "GRANT UPDATE, DELETE ON `test`.* TO 'scott'@'localhost'", + "GRANT USAGE ON *.* TO 'bob'@'localhost'", + ].normalize + end + + context 'when before subject' do + it do + expect_initial_state + end + end + context 'when grant privs' do subject { client(dry_run: true) } @@ -70,13 +90,7 @@ RUBY } - expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON `test`.* TO 'bob'@'localhost'", - "GRANT SELECT (user) ON `mysql`.`user` TO 'scott'@'localhost'", - "GRANT SELECT, INSERT ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL", - "GRANT UPDATE, DELETE ON `test`.* TO 'scott'@'localhost'", - "GRANT USAGE ON *.* TO 'bob'@'localhost'", - ].normalize + expect_initial_state end end @@ -108,13 +122,7 @@ RUBY } - expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON `test`.* TO 'bob'@'localhost'", - "GRANT SELECT (user) ON `mysql`.`user` TO 'scott'@'localhost'", - "GRANT SELECT, INSERT ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL", - "GRANT UPDATE, DELETE ON `test`.* TO 'scott'@'localhost'", - "GRANT USAGE ON *.* TO 'bob'@'localhost'", - ].normalize + expect_initial_state end end @@ -152,13 +160,7 @@ RUBY } - expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON `test`.* TO 'bob'@'localhost'", - "GRANT SELECT (user) ON `mysql`.`user` TO 'scott'@'localhost'", - "GRANT SELECT, INSERT ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE SSL", - "GRANT UPDATE, DELETE ON `test`.* TO 'scott'@'localhost'", - "GRANT USAGE ON *.* TO 'bob'@'localhost'", - ].normalize + expect_initial_state end end end diff --git a/spec/change/change_grants_4_spec.rb b/spec/change/change_grants_4_spec.rb index c09837d..7f9cd57 100644 --- a/spec/change/change_grants_4_spec.rb +++ b/spec/change/change_grants_4_spec.rb @@ -31,7 +31,7 @@ expect(show_grants).to match_array [ "GRANT USAGE ON *.* TO 'scott'@'localhost'", - ] + ].normalize end end end diff --git a/spec/change/change_grants_func_prcd_spec.rb b/spec/change/change_grants_func_prcd_spec.rb index c070f46..3680c7d 100644 --- a/spec/change/change_grants_func_prcd_spec.rb +++ b/spec/change/change_grants_func_prcd_spec.rb @@ -46,7 +46,7 @@ expect(show_grants).to match_array [ "GRANT USAGE ON *.* TO 'scott'@'%'", "GRANT EXECUTE ON PROCEDURE `#{TEST_DATABASE}`.`my_prcd` TO 'scott'@'%'" - ] + ].normalize end end @@ -88,7 +88,7 @@ expect(show_grants).to match_array [ "GRANT USAGE ON *.* TO 'scott'@'%'", "GRANT EXECUTE ON FUNCTION `#{TEST_DATABASE}`.`my_func` TO 'scott'@'%'" - ] + ].normalize end end end diff --git a/spec/create/create_user_2_spec.rb b/spec/create/create_user_2_spec.rb index 8ced796..ba44fb5 100644 --- a/spec/create/create_user_2_spec.rb +++ b/spec/create/create_user_2_spec.rb @@ -167,7 +167,7 @@ expect(show_grants).to match_array [ "GRANT SELECT, INSERT, UPDATE, DELETE ON *.* TO 'scott'@'localhost'", "GRANT SELECT, INSERT, UPDATE, DELETE ON `test`.* TO 'scott'@'localhost'", - ] + ].normalize end end end diff --git a/spec/create/create_user_3_spec.rb b/spec/create/create_user_3_spec.rb index 72c0a96..42acb48 100644 --- a/spec/create/create_user_3_spec.rb +++ b/spec/create/create_user_3_spec.rb @@ -24,6 +24,7 @@ } expect(result).to be_falsey + expect(show_create_users).to match_array [] expect(show_grants).to match_array [] end end @@ -78,10 +79,13 @@ RUBY } + expect(show_create_users).to match_array [ + start_with("CREATE USER `bob`@`%` IDENTIFIED WITH 'mysql_native_password' REQUIRE SSL PASSWORD EXPIRE"), + ] expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON *.* TO 'bob'@'%' REQUIRE SSL", - "GRANT SELECT ON `test`.* TO 'bob'@'%'", - ].normalize + *grant_all_priv(user: 'bob', host: '%'), + user_host_normalize("GRANT SELECT ON `test`.* TO 'bob'@'%'"), + ] end end @@ -89,6 +93,9 @@ subject { client(dry_run: true) } it do + expect(show_create_users).to match_array [] + expect(show_grants).to match_array [] + apply(subject) { <<-RUBY user 'scott', 'localhost', identified: 'tiger' do @@ -109,6 +116,7 @@ RUBY } + expect(show_create_users).to match_array [] expect(show_grants).to match_array [] end end diff --git a/spec/create/create_user_spec.rb b/spec/create/create_user_spec.rb index 953b830..d858558 100644 --- a/spec/create/create_user_spec.rb +++ b/spec/create/create_user_spec.rb @@ -13,6 +13,14 @@ subject { client } it do + # mysql> SELECT CONCAT('*', UPPER(SHA1(UNHEX(SHA1('expected_password'))))) AS PASSWORD; + # +-------------------------------------------+ + # | PASSWORD | + # +-------------------------------------------+ + # | *EC698E653632EA2249F2346B3C4BC081F370BDAE | + # +-------------------------------------------+ + # 1 row in set (0.00 sec) + quoted_expected_password = '*EC698E653632EA2249F2346B3C4BC081F370BDAE' result = apply(subject) { <<-RUBY user 'scott', 'localhost', identified: 'tiger' do @@ -30,14 +38,25 @@ grant 'DELETE' end end + +user 'mary', '%', identified: "PASSWORD '#{quoted_expected_password}'" do + on '*.*' do + grant 'USAGE' + end +end RUBY } expect(result).to be_truthy + expect(show_create_users).to match_array [ + start_with("CREATE USER `scott`@`localhost` IDENTIFIED WITH 'mysql_native_password' AS '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40' REQUIRE NONE PASSWORD EXPIRE"), + start_with("CREATE USER `mary`@`%` IDENTIFIED WITH 'mysql_native_password' AS '#{quoted_expected_password}' REQUIRE NONE PASSWORD EXPIRE"), + ] expect(show_grants).to match_array [ "GRANT SELECT, INSERT, UPDATE, DELETE ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40'", "GRANT SELECT, INSERT, UPDATE, DELETE ON `test`.* TO 'scott'@'localhost'", + "GRANT USAGE ON *.* TO 'mary'@'%'", ].normalize end end @@ -93,7 +112,7 @@ } expect(show_grants).to match_array [ - "GRANT ALL PRIVILEGES ON *.* TO 'bob'@'%' REQUIRE SSL", + *grant_all_priv(user: 'bob', host: '%'), "GRANT SELECT ON `test`.* TO 'bob'@'%'", "GRANT SELECT, INSERT, UPDATE, DELETE ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40'", "GRANT SELECT, INSERT, UPDATE, DELETE ON `test`.* TO 'scott'@'localhost'", diff --git a/spec/create/create_user_with_func_prcd_spec.rb b/spec/create/create_user_with_func_prcd_spec.rb index debaeed..40ce2d3 100644 --- a/spec/create/create_user_with_func_prcd_spec.rb +++ b/spec/create/create_user_with_func_prcd_spec.rb @@ -23,7 +23,7 @@ expect(show_grants).to match_array [ "GRANT USAGE ON *.* TO 'scott'@'localhost'", "GRANT EXECUTE ON FUNCTION `#{TEST_DATABASE}`.`foo` TO 'scott'@'localhost'" - ] + ].normalize end end end @@ -52,7 +52,7 @@ expect(show_grants).to match_array [ "GRANT USAGE ON *.* TO 'scott'@'localhost'", "GRANT EXECUTE ON PROCEDURE `#{TEST_DATABASE}`.`foo` TO 'scott'@'localhost'" - ] + ].normalize end end end diff --git a/spec/drop/drop_user_spec.rb b/spec/drop/drop_user_spec.rb index 544cbd7..c564b1a 100644 --- a/spec/drop/drop_user_spec.rb +++ b/spec/drop/drop_user_spec.rb @@ -52,7 +52,7 @@ expect(show_grants).to match_array [ "GRANT ALL PRIVILEGES ON `test`.* TO 'bob'@'localhost'", "GRANT USAGE ON *.* TO 'bob'@'localhost'", - ] + ].normalize end end diff --git a/spec/drop/expire_user_spec.rb b/spec/drop/expire_user_spec.rb index a63d10f..814943e 100644 --- a/spec/drop/expire_user_spec.rb +++ b/spec/drop/expire_user_spec.rb @@ -81,7 +81,7 @@ expect(show_grants).to match_array [ "GRANT ALL PRIVILEGES ON `test`.* TO 'bob'@'localhost'", "GRANT USAGE ON *.* TO 'bob'@'localhost'", - ] + ].normalize end end diff --git a/spec/export/export_spec.rb b/spec/export/export_spec.rb index 39ed975..cce6061 100644 --- a/spec/export/export_spec.rb +++ b/spec/export/export_spec.rb @@ -113,7 +113,10 @@ end it do - expect(subject.export.strip).to eq grantfile.strip + if RUBY_VERSION >= '3.4.0' + grantfile.sub!(/:with=>/, 'with: ') + end + expect(subject.export.strip).to match grantfile.strip end end end diff --git a/spec/misc/misc_spec.rb b/spec/misc/misc_spec.rb index fe73ab2..dc4add6 100644 --- a/spec/misc/misc_spec.rb +++ b/spec/misc/misc_spec.rb @@ -42,8 +42,15 @@ expect(logger).to receive(:debug).with("[DEBUG] SET SQL_LOG_BIN = 0") allow(logger).to receive(:debug).with('[DEBUG] SET SQL_MODE = ""') expect(logger).to receive(:debug).with("[DEBUG] SELECT user, host FROM mysql.user") - expect(logger).to receive(:info).with("GRANT SELECT, INSERT, UPDATE, DELETE ON *.* TO 'scott'@'localhost' IDENTIFIED BY 'tiger'") - expect(logger).to receive(:info).with("GRANT SELECT, INSERT, UPDATE, DELETE ON `test`.* TO 'scott'@'localhost' IDENTIFIED BY 'tiger'") + create_user(user: 'scott', host: 'localhost', identified: 'tiger', privs: %w(SELECT INSERT UPDATE DELETE)).each do |sql| + + expect(logger).to receive(:info).with(sql) + end + if mysql8_0? + expect(logger).to receive(:info).with("GRANT SELECT, INSERT, UPDATE, DELETE ON `test`.* TO 'scott'@'localhost'") + else + expect(logger).to receive(:info).with("GRANT SELECT, INSERT, UPDATE, DELETE ON `test`.* TO 'scott'@'localhost' IDENTIFIED BY 'tiger'") + end expect(logger).to receive(:info).with("FLUSH PRIVILEGES") logger end @@ -72,7 +79,7 @@ } expect(show_grants).to match_array [ - "GRANT SELECT, INSERT, UPDATE, DELETE ON *.* TO 'scott'@'localhost' IDENTIFIED BY PASSWORD '*F2F68D0BB27A773C1D944270E5FAFED515A3FA40'", + *create_user(user: 'scott', host: 'localhost', privs: %w(SELECT INSERT UPDATE DELETE), skip_create_user: true), "GRANT SELECT, INSERT, UPDATE, DELETE ON `test`.* TO 'scott'@'localhost'", ].normalize end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 459a6b5..c9136d5 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -4,7 +4,7 @@ require 'tempfile' require 'timecop' -IGNORE_USER = /\A(|root)\z/ +IGNORE_USER = /\A((|root)\z|mysql\.)/ TEST_DATABASE = 'gratan_test' RSpec.configure do |config| @@ -18,7 +18,11 @@ def env_empty?(str) end def mysql5_7? - !env_empty?(ENV['MYSQL5_7']) + !env_empty?(ENV['MYSQL5_7']) || mysql8_0? +end + +def mysql8_0? + !env_empty?(ENV['MYSQL8_0']) end MYSQL_PORT = if ENV['MYSQL_PORT'].blank? @@ -128,23 +132,39 @@ def clean_grants end end -def show_grants - grants = [] +def show_create_users + create_users = [] mysql do |client| select_users(client).each do |user, host| next if IGNORE_USER =~ user user_host = "'%s'@'%s'" % [client.escape(user), client.escape(host)] - client.query("SHOW GRANTS FOR #{user_host}").each do |row| - grants << row.values.first + client.query("SHOW CREATE USER #{user_host}").each do |row| + create_users << row.values.first end - end end - if mysql5_7? - grants.each do |grant| + create_users.sort +end + +def show_grants + grants = [] + + priv_re = Regexp.union(Gratan::GrantParser::STATIC_PRIVS) + + mysql do |client| + select_users(client).each do |user, host| + next if IGNORE_USER =~ user + user_host = "'%s'@'%s'" % [client.escape(user), client.escape(host)] + + client.query("SHOW GRANTS FOR #{user_host}").each do |row| + grant = row.values.first + grant_without_grant_option = grant.sub(/\s+WITH\s+GRANT\s+OPTION\z/, '') + next unless priv_re.match?(grant_without_grant_option) + grants << grant + end end end @@ -203,13 +223,119 @@ def apply(cli = client) end end +def grant_all_priv(user:, host: '%', database: '*', table: '*', auth: nil, required: 'SSL', with: nil) + auth_clause = auth.blank? ? '' : " #{auth}" + require_clause = required.blank? ? '' : " REQUIRE #{required}" + with_clause = with.blank? ? '' : " WITH #{with}" + + if mysql8_0? + all_privs = [ + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'CREATE', + 'DROP', + 'RELOAD', + 'SHUTDOWN', + 'PROCESS', + 'FILE', + 'REFERENCES', + 'INDEX', + 'ALTER', + 'SHOW DATABASES', + 'SUPER', + 'CREATE TEMPORARY TABLES', + 'LOCK TABLES', + 'EXECUTE', + 'REPLICATION SLAVE', + 'REPLICATION CLIENT', + 'CREATE VIEW', + 'SHOW VIEW', + 'CREATE ROUTINE', + 'ALTER ROUTINE', + 'CREATE USER', + 'EVENT', + 'TRIGGER', + 'CREATE TABLESPACE', + 'CREATE ROLE', + 'DROP ROLE', + ] + [ + "GRANT #{all_privs.join(', ')} ON #{database}.#{table} TO `#{user}`@`#{host}`#{with_clause}", + ] + else + [ + "GRANT ALL PRIVILEGES ON #{database}.#{table} TO '#{user}'@'#{host}'#{auth_clause}#{require_clause}#{with_clause}", + ] + end +end + +def create_user(user:, host: '%', database: '*', table: '*', identified: nil, password: nil, privs: nil, skip_create_user: false) + if privs.nil? + privs = ['USAGE'] + end + if mysql8_0? + identified_with = if !identified.nil? + " IDENTIFIED WITH mysql_native_password BY '#{identified}'" + else + '' + end + sqls = [ + "CREATE USER '#{user}'@'#{host}'#{identified_with}", + "GRANT #{privs.join(', ')} ON #{database}.#{table} TO '#{user}'@'#{host}'", + ] + if skip_create_user + sqls[1..-1] + else + sqls + end + else + identified_by = if !identified.nil? + %( IDENTIFIED BY '#{identified}') + elsif !password.nil? + %( IDENTIFIED BY PASSWORD '#{password}') + else + '' + end + [ + "GRANT #{privs.join(', ')} ON #{database}.#{table} TO '#{user}'@'#{host}'#{identified_by}", + ] + end +end + +def user_host_normalize(str) + if mysql8_0? + str.sub(/'([^']+)'@'([^']+)'/, '`\1`@`\2`') + else + str + end +end + class Array def normalize if mysql5_7? self.map do |i| - i.sub(/ IDENTIFIED BY PASSWORD '[^']+'/, '') - .sub(/ REQUIRE \w+\b/, '') - .sub(/ WITH GRANT OPTION [\w ]+\z/, ' WITH GRANT OPTION') + ii = i.sub(/ IDENTIFIED BY PASSWORD '[^']+'/, '') + .sub(/ REQUIRE \w+\b/, '') + .sub(/ WITH GRANT OPTION [\w ]+\z/, ' WITH GRANT OPTION') + if mysql8_0? + ii.sub(/'([^']+)'@'([^']+)'/, '`\1`@`\2`') + .sub(/((?:\A| )GRANT )(.*?)( ON )/) do + grant, body, on = $1, $2, $3 + new_body = body.split(/, /).map do |priv_type_columns| + _, priv_type, cols = */\A([^\(]+)\(([^\)]+)\)\z/.match(priv_type_columns) + if priv_type && cols + "#{priv_type}(#{cols.split(/, /).map { |c| "`#{c}`" }.join(', ')})" + else + priv_type_columns + end + end.join(', ') + "#{grant}#{new_body}#{on}" + end + else + ii + end end else self