├── .circleci └── config.yml ├── .gitignore ├── .rspec ├── .rubocop.yml ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── exe └── mysqlman ├── lib ├── mysqlman.rb └── mysqlman │ ├── all_privileges.yml │ ├── cli.rb │ ├── connection.rb │ ├── initializer.rb │ ├── privs.rb │ ├── privs_grant.rb │ ├── privs_util.rb │ ├── processor.rb │ ├── role.rb │ ├── user.rb │ └── version.rb ├── mysqlman.gemspec └── spec ├── connection_spec.rb ├── dummy ├── config │ └── manager.yml ├── excludes.d │ └── default.yml ├── roles.d │ └── engineer.yml └── users.d │ └── engineers.yml ├── mysqlman_spec.rb ├── privs_grant.rb ├── privs_spec.rb ├── privs_util.rb ├── role_spec.rb ├── spec_helper.rb └── user_spec.rb /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | build: 4 | parallelism: 1 5 | working_directory: ~/mysqlman 6 | 7 | docker: 8 | - image: circleci/ruby:2.4.1 9 | - image: circleci/mysql:5.7 10 | environment: 11 | - MYSQL_ALLOW_EMPTY_PASSWORD=true 12 | - MYSQL_DATABASE=test 13 | - MYSQL_USER=root 14 | - MYSQL_ROOT_HOST=% 15 | 16 | steps: 17 | - checkout 18 | - run: 19 | name: deps 20 | command: | 21 | sudo apt-get update --fix-missing 22 | sudo apt-get install -y --force-yes --no-install-recommends mysql-client 23 | - run: 24 | name: Wait for db 25 | command: dockerize -wait tcp://localhost:3306 -timeout 1m 26 | 27 | 28 | # Bundle install dependencies 29 | - run: bundle install --jobs=4 --retry=3 --path=vendor/bundle 30 | 31 | - run: 32 | name: Run RSpec 33 | command: cd spec/dummy && bundle exec rspec ../* --format documentation --require ../spec_helper.rb 34 | - run: 35 | name: Run rubocop 36 | command: bundle exec rubocop ./lib/**/* 37 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | /bin/console 10 | /bin/setup 11 | # rspec failure tracking 12 | .rspec_status 13 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | Style/Documentation: 2 | Enabled: false 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | git_source(:github) { |repo_name| "https://github.com/#{repo_name}" } 4 | 5 | # Specify your gem's dependencies in mysqlman.gemspec 6 | gemspec 7 | gem 'pry' 8 | gem 'rspec' 9 | gem 'rubocop' 10 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | mysqlman (0.1.1) 5 | mysql2 6 | thor 7 | 8 | GEM 9 | remote: https://rubygems.org/ 10 | specs: 11 | ast (2.4.0) 12 | coderay (1.1.2) 13 | diff-lcs (1.3) 14 | method_source (0.9.0) 15 | mysql2 (0.4.10) 16 | parallel (1.12.1) 17 | parser (2.5.0.4) 18 | ast (~> 2.4.0) 19 | powerpack (0.1.1) 20 | pry (0.11.3) 21 | coderay (~> 1.1.0) 22 | method_source (~> 0.9.0) 23 | rainbow (3.0.0) 24 | rake (10.5.0) 25 | rspec (3.7.0) 26 | rspec-core (~> 3.7.0) 27 | rspec-expectations (~> 3.7.0) 28 | rspec-mocks (~> 3.7.0) 29 | rspec-core (3.7.1) 30 | rspec-support (~> 3.7.0) 31 | rspec-expectations (3.7.0) 32 | diff-lcs (>= 1.2.0, < 2.0) 33 | rspec-support (~> 3.7.0) 34 | rspec-mocks (3.7.0) 35 | diff-lcs (>= 1.2.0, < 2.0) 36 | rspec-support (~> 3.7.0) 37 | rspec-support (3.7.0) 38 | rubocop (0.53.0) 39 | parallel (~> 1.10) 40 | parser (>= 2.5) 41 | powerpack (~> 0.1) 42 | rainbow (>= 2.2.2, < 4.0) 43 | ruby-progressbar (~> 1.7) 44 | unicode-display_width (~> 1.0, >= 1.0.1) 45 | ruby-progressbar (1.9.0) 46 | thor (0.20.0) 47 | unicode-display_width (1.3.0) 48 | 49 | PLATFORMS 50 | ruby 51 | 52 | DEPENDENCIES 53 | bundler (~> 1.16.a) 54 | mysqlman! 55 | pry 56 | rake (~> 10.0) 57 | rspec 58 | rubocop 59 | 60 | BUNDLED WITH 61 | 1.16.0 62 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 onunu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Mysqlman 2 | 3 | [![Gem Version](https://badge.fury.io/rb/mysqlman.svg)](https://badge.fury.io/rb/mysqlman) 4 | [![CircleCI](https://circleci.com/gh/onunu/mysqlman/tree/master.svg?style=svg)](https://circleci.com/gh/onunu/mysqlman/tree/master) 5 | [![Maintainability](https://api.codeclimate.com/v1/badges/f3c9e8075c6482919d25/maintainability)](https://codeclimate.com/github/onunu/mysqlman/maintainability) 6 | [![MIT License](http://img.shields.io/badge/license-MIT-blue.svg?style=flat)](LICENSE) 7 | 8 | Mysqlman is a tool manage users for MySQL. 9 | You can start management with writing some yaml files and executing some commands. 10 | And mysqlman provide feature to manage privileges(global, schema, table, but column) 11 | 12 | ## Installation 13 | 14 | Add this line to your application's Gemfile: 15 | 16 | ```ruby 17 | gem 'mysqlman' 18 | ``` 19 | 20 | And then execute: 21 | 22 | ``` 23 | $ bundle 24 | ``` 25 | 26 | Or install it yourself as: 27 | 28 | ``` 29 | $ gem install mysqlman 30 | ``` 31 | 32 | ## Usage 33 | ### 1. Setup 34 | Firstly, please create file for connecting MySQL. 35 | Please set the file in executing dir `config/manager.yml` 36 | 37 | ```yml 38 | --- 39 | host: 127.0.0.1 40 | username: root 41 | password: passw0rd 42 | ``` 43 | 44 | #### Caution 45 | The manager needs some privileges. 46 | The read privileges to manage other users are following. 47 | 48 | |schema|table|columns| 49 | |:-----|:----|:------| 50 | |mysql | user|User, Host| 51 | |information_schema|USER_PRIVILEGES|PRIVILEGE_TYPE, IS_GRANTABLE| 52 | |information_schema|SCHEMA_PRIVILEGES|TABLE_SCHEMA, PRIVILEGE_TYPE, IS_GRANTABLE| 53 | |information_schema|TABLE_PRIVILEGES|TABLE_SCHEMA, TABLE_NAME, PRIVILEGE_TYPE, IS_GRANTABLE| 54 | 55 | And ofcourse, the manager needs privileges that you want to manage with grant option. 56 | 57 | ### 2. Initialize 58 | Second, initialize the config. 59 | In initializing, mysqlman do followings. 60 | 61 | - Create each directories(roles.d, users.d, excludes.d) 62 | - Create exclude users config 63 | 64 | Execute: 65 | 66 | ``` 67 | $ mysqlman init 68 | ``` 69 | 70 | Exclude users (=Unmanaged users) are that users are already exist in MySQL. 71 | Exclude users are written in `excludes.d/default.yml` by default. 72 | If you want to add unmanaged user, or to manage user written in excludes config, please edit the file by yourself. 73 | 74 | ### 3. Write config 75 | Write user, role settings. 76 | please confirm how to write them. 77 | 78 | #### 3-1 Role 79 | Role is config of database privileges. 80 | All users are belong to one of roles. 81 | 82 | In `roles.d/engineer.yml` as example: 83 | 84 | ```yml 85 | --- 86 | engineer: # require: as a role name 87 | global: # optional: global privileges 88 | - select 89 | schema: # optional: schema privileges 90 | example_schema1: # requrie: schema name 91 | - update 92 | - insert 93 | example_schema2: 94 | - update 95 | table: # optional: table privileges 96 | example_schema1: # require: schema name 97 | example_table: # require: table name 98 | - delete: 99 | ``` 100 | 101 | You can write privilege type in format of followings. 102 | 103 | - OK: 104 | - CREATE USER 105 | - create user 106 | - CREATE_USER 107 | - create_user 108 | - NG: 109 | - CREATEUSER 110 | - createuser 111 | 112 | ##### Special privileges 113 | ###### ALL 114 | `ALL` type privileges alias of some some privileges of the target level. 115 | Please confirm following. 116 | 117 | (WIP) 118 | [All privileges](https://github.com/onunu/mysqlman/blob/master/lib/mysqlman/all_privileges.yml) 119 | 120 | ###### GRANT OPTION 121 | `GRANT OPTION` is not included in `ALL` privileges. 122 | If you want add the privileges, please set bot of them. 123 | 124 | #### 3-2 User 125 | User is config of information to connect Mysql. 126 | All users are belong one of roles. 127 | 128 | In `users.d/engineers.yml`: 129 | 130 | ```yml 131 | --- 132 | engineer: # require: the role name 133 | - onunu: # require: user name 134 | - application_user: 135 | host: 10.0.0.1 # optional: connectable host(default '%') 136 | ``` 137 | 138 | ### 4. Apply settings 139 | After writing settings, please apply them. 140 | 141 | #### dry-run 142 | You can confirm changes witout appling settings. 143 | 144 | ``` 145 | $ mysqlman dryrun 146 | ``` 147 | 148 | #### apply 149 | If the changes are same as your plan, please execute apply command. 150 | 151 | ``` 152 | $ mysqlman apply 153 | ``` 154 | 155 | Changes are put in STDOUT. 156 | 157 | ## Contributing 158 | 159 | Bug reports and pull requests are welcome on GitHub at https://github.com/onunu/mysqlman. 160 | 161 | ## License 162 | 163 | The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). 164 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | require 'rspec/core/rake_task' 3 | 4 | RSpec::Core::RakeTask.new(:spec) 5 | 6 | task default: :spec 7 | -------------------------------------------------------------------------------- /exe/mysqlman: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'mysqlman' 3 | 4 | Mysqlman::CLI.start 5 | -------------------------------------------------------------------------------- /lib/mysqlman.rb: -------------------------------------------------------------------------------- 1 | require 'mysqlman/version' 2 | require 'mysqlman/user' 3 | require 'mysqlman/role' 4 | require 'mysqlman/initializer' 5 | require 'mysqlman/processor' 6 | require 'mysqlman/privs' 7 | require 'mysqlman/connection' 8 | require 'mysqlman/cli' 9 | 10 | module Mysqlman 11 | EXE_DIR = Dir.pwd 12 | EXCLUDE_DIR = File.join(EXE_DIR, 'excludes.d') 13 | EXCLUDE_FILE = File.join(EXCLUDE_DIR, 'default.yml') 14 | 15 | ROLE_DIR = File.join(EXE_DIR, 'roles.d') 16 | USER_DIR = File.join(EXE_DIR, 'users.d') 17 | 18 | MANAGER_CONFIG = File.join(EXE_DIR, 'config', 'manager.yml') 19 | 20 | HOST_ALL = '%'.freeze 21 | end 22 | -------------------------------------------------------------------------------- /lib/mysqlman/all_privileges.yml: -------------------------------------------------------------------------------- 1 | --- 2 | table: &table 3 | ALTER: 4 | CREATE VIEW: 5 | CREATE: 6 | DELETE: 7 | DROP: 8 | INDEX: 9 | INSERT: 10 | SELECT: 11 | SHOW VIEW: 12 | TRIGGER: 13 | UPDATE: 14 | 15 | schema: &schema 16 | <<: *table 17 | CREATE: 18 | DROP: 19 | EVENT: 20 | LOCK TABLES: 21 | 22 | global: 23 | <<: *schema 24 | CREATE TABLESPACE: 25 | CREATE USER: 26 | FILE: 27 | PROCESS: 28 | RELOAD: 29 | REPLICATION CLIENT: 30 | REPLICATION SLAVE: 31 | SHOW DATABASES: 32 | SHUTDOWN: 33 | SUPER: 34 | -------------------------------------------------------------------------------- /lib/mysqlman/cli.rb: -------------------------------------------------------------------------------- 1 | require 'thor' 2 | 3 | module Mysqlman 4 | class CLI < Thor 5 | desc 'init', 'initialize settings' 6 | long_desc <<-LONGDESC 7 | Require: `config/manager.yml` : to connect MySQL. 8 | Create: `excludes.d/default.yml`, `roles.d/`, `users.d/` 9 | When you want see how to write or roles some files, please confirm README on Github. 10 | LONGDESC 11 | def init 12 | Initializer.new.init 13 | end 14 | 15 | desc 'apply', 'apply settings' 16 | def apply 17 | Processor.new.apply 18 | end 19 | 20 | desc 'dryrun', 'confirm settings, with dry-run' 21 | def dryrun 22 | Processor.new.apply(true) 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/mysqlman/connection.rb: -------------------------------------------------------------------------------- 1 | require 'mysql2' 2 | require 'yaml' 3 | require 'singleton' 4 | require 'logger' 5 | 6 | module Mysqlman 7 | class Connection 8 | include Singleton 9 | 10 | attr_accessor :conn 11 | 12 | def initialize 13 | config = YAML.load_file(MANAGER_CONFIG).map { |k, v| [k.to_sym, v] }.to_h 14 | config.merge(database: 'mysql') 15 | @conn = Mysql2::Client.new(config) 16 | end 17 | 18 | def query(query_string) 19 | @conn.query(query_string) 20 | end 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /lib/mysqlman/initializer.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | 3 | module Mysqlman 4 | class Initializer 5 | def initialize 6 | @conn = Connection.instance 7 | @logger = Logger.new(STDOUT) 8 | end 9 | 10 | # rubocop:disable LineLength 11 | def init 12 | File.exist?(EXCLUDE_FILE) ? @logger.info('skip: creation excludes.d') : create_exclude_config 13 | Dir.exist?(ROLE_DIR) ? @logger.info('skip: creation roles.d') : create_roles_dir 14 | Dir.exist?(USER_DIR) ? @logger.info('skip: creation users.d') : create_users_dir 15 | end 16 | # rubocop:enable LineLength 17 | 18 | private 19 | 20 | def create_exclude_config 21 | unless Dir.exist?(EXCLUDE_DIR) 22 | Dir.mkdir(EXCLUDE_DIR) 23 | @logger.info("created: #{EXCLUDE_DIR}") 24 | end 25 | File.open(EXCLUDE_FILE, 'w') do |file| 26 | file.puts(User.all.map(&:name_with_host).to_yaml) 27 | end 28 | @logger.info("created: #{EXCLUDE_FILE}") 29 | end 30 | 31 | def create_roles_dir 32 | Dir.mkdir(ROLE_DIR) 33 | @logger.info("created: #{ROLE_DIR}") 34 | end 35 | 36 | def create_users_dir 37 | Dir.mkdir(USER_DIR) 38 | @logger.info("created: #{USER_DIR}") 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/mysqlman/privs.rb: -------------------------------------------------------------------------------- 1 | require 'logger' 2 | require 'mysqlman/privs_util' 3 | require 'mysqlman/privs_grant' 4 | 5 | module Mysqlman 6 | class Privs 7 | extend PrivsUtil 8 | include PrivsGrant 9 | 10 | def initialize(user) 11 | @user = user 12 | @conn = Connection.instance 13 | @logger = Logger.new(STDOUT) 14 | end 15 | 16 | def fetch 17 | reload_privs 18 | end 19 | 20 | private 21 | 22 | def reload_privs 23 | [global_privs, schema_privs, table_privs].compact.flatten 24 | end 25 | 26 | def global_privs 27 | privs = fetch_privs( 28 | 'information_schema.USER_PRIVILEGES', 29 | %w[PRIVILEGE_TYPE IS_GRANTABLE] 30 | ) 31 | format_privs(add_grantable(privs)) 32 | end 33 | 34 | def schema_privs 35 | privs = fetch_privs( 36 | 'information_schema.SCHEMA_PRIVILEGES', 37 | %w[TABLE_SCHEMA PRIVILEGE_TYPE IS_GRANTABLE] 38 | ) 39 | format_privs(add_grantable(privs)) 40 | end 41 | 42 | def table_privs 43 | privs = fetch_privs( 44 | 'information_schema.TABLE_PRIVILEGES', 45 | %w[TABLE_NAME TABLE_SCHEMA PRIVILEGE_TYPE IS_GRANTABLE] 46 | ) 47 | format_privs(add_grantable(privs)) 48 | end 49 | 50 | def fetch_privs(table, columns) 51 | @conn.query(fetch_query(table, columns)).map do |row| 52 | { 53 | schema: row['TABLE_SCHEMA'], 54 | table: row['TABLE_NAME'], 55 | type: row['PRIVILEGE_TYPE'], 56 | grant: row['IS_GRANTABLE'] == 'YES' 57 | } 58 | end 59 | end 60 | 61 | def fetch_query(table, columns) 62 | <<-SQL 63 | SELECT #{columns.join(',')} 64 | FROM #{table} 65 | WHERE 66 | GRANTEE = '\\\'#{@user.user}\\\'@\\\'#{@user.host}\\\'' 67 | SQL 68 | end 69 | 70 | def add_grantable(privs) 71 | privs.uniq { |priv| [priv[:schema], priv[:table]] }.each do |names| 72 | is_grant = grantable_collection?(privs, names) 73 | next unless is_grant 74 | privs.push( 75 | schema: names[:schema], table: names[:table], type: 'GRANT OPTION' 76 | ) 77 | end 78 | privs 79 | end 80 | 81 | def grantable_collection?(privs, names) 82 | collection = privs.select do |priv| 83 | priv[:schema] == names[:schema] && priv[:table] == names[:table] 84 | end 85 | collection.all? { |priv| priv[:grant] } 86 | end 87 | 88 | def format_privs(privs) 89 | privs.reject { |p| p[:type] == 'USAGE' }.map do |priv| 90 | priv.delete(:grant) 91 | priv 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/mysqlman/privs_grant.rb: -------------------------------------------------------------------------------- 1 | module Mysqlman 2 | module PrivsGrant 3 | def revoke(priv, debug = false) 4 | query = "REVOKE #{priv[:type]} ON #{target_lebel(priv)} FROM #{user_info}" 5 | @conn.query(query) unless debug 6 | @logger.info(query) 7 | end 8 | 9 | def grant(priv, debug = false) 10 | query = "GRANT #{priv[:type]} ON #{target_lebel(priv)} TO #{user_info}" 11 | @conn.query(query) unless debug 12 | @logger.info(query) 13 | end 14 | 15 | private 16 | 17 | def user_info 18 | "'#{@user.user}'@'#{@user.host}'" 19 | end 20 | 21 | def target_lebel(priv) 22 | "#{priv[:schema] || '*'}.#{priv[:table] || '*'}" 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/mysqlman/privs_util.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | module Mysqlman 4 | module PrivsUtil 5 | def all(schema, table, grantable) 6 | privs = all_privs(schema, table, lebel(schema, table)) 7 | grantable ? privs.push(grant_option(schema, table)) : privs 8 | end 9 | 10 | private 11 | 12 | def all_privs(schema, table, key) 13 | load_privs(key).map do |priv| 14 | { schema: schema, table: table, type: priv } 15 | end 16 | end 17 | 18 | def lebel(schema, table) 19 | if schema && table 20 | 'table' 21 | elsif schema 22 | 'schema' 23 | else 24 | 'global' 25 | end 26 | end 27 | 28 | def load_privs(key) 29 | YAML.load_file(File.join(__dir__, 'all_privileges.yml'))[key].keys 30 | end 31 | 32 | def grant_option(schema = nil, table = nil) 33 | { schema: schema, table: table, type: 'GRANT OPTION' } 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/mysqlman/processor.rb: -------------------------------------------------------------------------------- 1 | module Mysqlman 2 | class Processor 3 | def initialize 4 | @current_users = current_users 5 | @managed_users = managed_users 6 | end 7 | 8 | def apply(debug = false) 9 | delete_unknown_user(debug) 10 | create_shortage_user(debug) 11 | revoke_extra_privileges(debug) 12 | grant_shortage_privileges(debug) 13 | end 14 | 15 | private 16 | 17 | def current_users 18 | User.all.map do |user| 19 | exclude_users.include?(user: user.user, host: user.host) ? nil : user 20 | end.compact 21 | end 22 | 23 | def exclude_users 24 | @exclude_users ||= Dir.glob("#{EXCLUDE_DIR}/*.yml").map do |file| 25 | YAML.load_file(file).map do |u| 26 | { user: u['user'], host: u['host'] || '%' } 27 | end 28 | end.flatten 29 | end 30 | 31 | # rubocop:disable Metrics/MethodLength 32 | def managed_users 33 | Dir.glob("#{USER_DIR}/*.yml").map do |file| 34 | YAML.load_file(file).map do |role, users| 35 | users.map do |user| 36 | User.new( 37 | role: role, 38 | user: user.keys.first, 39 | host: user['host'] || HOST_ALL 40 | ) 41 | end 42 | end 43 | end.flatten 44 | end 45 | # rubocop:enable Metrics/MethodLength 46 | 47 | def delete_unknown_user(_debug) 48 | @current_users.each do |cu| 49 | cu.drop unless @managed_users.any? do |mu| 50 | cu.user == mu.user && cu.host == mu.host 51 | end 52 | end 53 | end 54 | 55 | def create_shortage_user(_debug) 56 | @managed_users.each do |user| 57 | user.create unless user.exists? 58 | end 59 | end 60 | 61 | def revoke_extra_privileges(debug) 62 | @managed_users.each do |user| 63 | (user.privs.fetch - user.role.privs).each do |priv| 64 | user.privs.revoke(priv, debug) 65 | end 66 | end 67 | end 68 | 69 | def grant_shortage_privileges(debug) 70 | @managed_users.each do |user| 71 | (user.role.privs - user.privs.fetch).each do |priv| 72 | user.privs.grant(priv, debug) 73 | end 74 | end 75 | end 76 | end 77 | end 78 | -------------------------------------------------------------------------------- /lib/mysqlman/role.rb: -------------------------------------------------------------------------------- 1 | require 'yaml' 2 | 3 | module Mysqlman 4 | class Role 5 | class << self 6 | def all 7 | files = Dir.glob("#{ROLE_DIR}/*.yml") 8 | files.map do |file| 9 | YAML.load_file(file).map do |name, config| 10 | new(name, config) 11 | end 12 | end.flatten 13 | end 14 | 15 | def find(name) 16 | roles = all 17 | roles.select { |role| role.name == name }.first 18 | end 19 | end 20 | 21 | attr_reader :name 22 | 23 | def initialize(name, config) 24 | @name = name 25 | @config = config 26 | end 27 | 28 | def privs 29 | [global_privs, schema_privs, table_privs].compact.flatten 30 | end 31 | 32 | def global_privs 33 | return if @config['global'].nil? 34 | parse_privs(@config['global']) 35 | end 36 | 37 | def schema_privs 38 | return if @config['schema'].nil? 39 | @config['schema'].map do |schema_name, privs| 40 | parse_privs(privs, schema_name) 41 | end 42 | end 43 | 44 | def table_privs 45 | return if @config['table'].nil? 46 | @config['table'].map do |schema_name, table_config| 47 | table_config.map do |table_name, privs| 48 | parse_privs(privs, schema_name, table_name) 49 | end 50 | end 51 | end 52 | 53 | def parse_privs(privs, schema = nil, table = nil) 54 | return Privs.all(schema, table, grantable?(privs)) if all_priv?(privs) 55 | privs.map do |priv| 56 | { 57 | schema: schema, 58 | table: table, 59 | type: priv.upcase.tr('_', ' ') 60 | } 61 | end 62 | end 63 | 64 | def grantable?(privs) 65 | privs.map(&:upcase).any? do |priv| 66 | priv.tr('_', ' ') == 'GRANT OPTION' 67 | end 68 | end 69 | 70 | def all_priv?(privs) 71 | privs.map(&:upcase).include?('ALL') 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /lib/mysqlman/user.rb: -------------------------------------------------------------------------------- 1 | require 'securerandom' 2 | require 'logger' 3 | 4 | module Mysqlman 5 | class User 6 | PASSWORD_LENGTH = 8 7 | class << self 8 | def all 9 | conn = Connection.instance 10 | conn.query('SELECT Host, User FROM mysql.user').map do |row| 11 | new(host: row['Host'], user: row['User']) 12 | end 13 | end 14 | 15 | def find(user, host = HOST_ALL) 16 | conn = Connection.instance 17 | user = conn.query(<<-QUERY 18 | SELECT Host, User 19 | FROM mysql.user 20 | WHERE Host = '#{host}' AND User = '#{user}' 21 | QUERY 22 | ).first 23 | new(host: user['Host'], user: user['User']) unless user.nil? 24 | end 25 | end 26 | 27 | attr_reader :user, :host, :role, :privs 28 | 29 | def initialize(user:, host: HOST_ALL, role: nil) 30 | @host = host 31 | @user = user 32 | @role = Role.find(role) unless role.nil? 33 | @privs = Privs.new(self) 34 | @conn = Connection.instance 35 | end 36 | 37 | def name_with_host 38 | { 'user' => @user, 'host' => @host } 39 | end 40 | 41 | def exists? 42 | user = @conn.query(<<-QUERY 43 | SELECT Host, User 44 | FROM mysql.user 45 | WHERE Host = '#{@host}' AND User = '#{@user}' 46 | QUERY 47 | ).first 48 | !user.nil? 49 | end 50 | 51 | def create(debug = false) 52 | password = debug ? '******' : SecureRandom.urlsafe_base64(PASSWORD_LENGTH) 53 | @conn.query(create_user_query(password)) unless debug 54 | Logger.new(STDOUT).info( 55 | "Create user: '#{@user}'@'#{@host}', password is '#{password}'" 56 | ) 57 | self 58 | end 59 | 60 | def create_user_query(password) 61 | "CREATE USER '#{@user}'@'#{@host}' IDENTIFIED BY '#{password}'" 62 | end 63 | 64 | def drop(debug = false) 65 | @conn.query("DROP USER '#{@user}'@'#{@host}'") unless debug 66 | Logger.new(STDOUT).info("Delete user: '#{@user}'@'#{@host}'") 67 | end 68 | end 69 | end 70 | -------------------------------------------------------------------------------- /lib/mysqlman/version.rb: -------------------------------------------------------------------------------- 1 | module Mysqlman 2 | VERSION = '0.1.1'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /mysqlman.gemspec: -------------------------------------------------------------------------------- 1 | lib = File.expand_path('lib', __dir__) 2 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 3 | require 'mysqlman/version' 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = 'mysqlman' 7 | spec.version = Mysqlman::VERSION 8 | spec.authors = ['onunu'] 9 | spec.email = ['riku.onuma@livesense.co.jp', 'onunu@zeals.co.jp'] 10 | 11 | spec.summary = 'Management your mysql users.' 12 | spec.description = 'Management your mysql users. You can do that by simple settings written by yaml' 13 | spec.homepage = 'https://github.com/onunu/mysqlman' 14 | spec.license = 'MIT' 15 | 16 | spec.files = `git ls-files -z`.split("\x0").reject do |f| 17 | f.match(%r{^(test|spec|features)/}) 18 | end 19 | spec.bindir = 'exe' 20 | spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) } 21 | spec.require_paths = ['lib'] 22 | 23 | spec.add_dependency 'mysql2' 24 | spec.add_dependency 'thor' 25 | 26 | spec.add_development_dependency 'bundler', '~> 1.16.a' 27 | spec.add_development_dependency 'rake', '~> 10.0' 28 | spec.add_development_dependency 'rspec', '~> 3.0' 29 | end 30 | -------------------------------------------------------------------------------- /spec/connection_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Mysqlman::Connection do 2 | describe '#query' do 3 | it 'can execute any query' do 4 | expect(Mysqlman::Connection.instance.query('SELECT VERSION()')).not_to be nil 5 | end 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/dummy/config/manager.yml: -------------------------------------------------------------------------------- 1 | --- 2 | host: 127.0.0.1 3 | username: root 4 | -------------------------------------------------------------------------------- /spec/dummy/excludes.d/default.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - user: mysql.session 3 | host: localhost 4 | - user: mysql.sys 5 | host: localhost 6 | - user: root 7 | host: localhost 8 | -------------------------------------------------------------------------------- /spec/dummy/roles.d/engineer.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engineer: 3 | global: 4 | - select 5 | - update 6 | schema: 7 | test: 8 | - grant_option 9 | - select 10 | - update 11 | - insert 12 | table: 13 | test: 14 | test_accounts: 15 | - delete 16 | -------------------------------------------------------------------------------- /spec/dummy/users.d/engineers.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engineer: 3 | - onunu: 4 | -------------------------------------------------------------------------------- /spec/mysqlman_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Mysqlman do 2 | it 'has a version number' do 3 | expect(Mysqlman::VERSION).not_to be nil 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /spec/privs_grant.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Mysqlman::PrivsGrant do 2 | let(:tester) { Mysqlman::User.new(user: 'tester').create } 3 | let(:test_priv) { { schema: nil, table: nil, type: 'SELECT' } } 4 | 5 | describe '#grant' do 6 | context 'no debug mode' do 7 | it 'the priv granted' do 8 | tester.privs.grant(test_priv) 9 | expect(tester.privs.fetch.include?(test_priv)).to eq true 10 | end 11 | end 12 | context 'debug mode' do 13 | it 'the priv did not grant' do 14 | tester.privs.grant(test_priv, true) 15 | expect(tester.privs.fetch.include?(test_priv)).to eq false 16 | end 17 | end 18 | end 19 | 20 | describe '#revoke' do 21 | context 'no debug mode' do 22 | it 'the priv revoked' do 23 | tester.privs.grant(test_priv) 24 | tester.privs.revoke(test_priv) 25 | expect(tester.privs.fetch.include?(test_priv)).to eq false 26 | end 27 | end 28 | context 'debug mode' do 29 | it 'the priv did not revoke' do 30 | tester.privs.grant(test_priv) 31 | tester.privs.revoke(test_priv, true) 32 | expect(tester.privs.fetch.include?(test_priv)).to eq true 33 | end 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /spec/privs_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Mysqlman::Privs do 2 | describe '#fetch' do 3 | context 'nomal privs' do 4 | before do 5 | Mysqlman::User.new(user: 'tester').create 6 | Mysqlman::Connection.instance.query('GRANT SELECT ON *.* TO \'tester\'@\'%\'') 7 | end 8 | let(:tester) { Mysqlman::User.new(user: 'tester') } 9 | it 'return privs array' do 10 | expect(tester.privs.fetch).to eq [{ schema: nil, table: nil, type: 'SELECT' }] 11 | end 12 | end 13 | context 'include grant option' do 14 | before do 15 | Mysqlman::User.new(user: 'tester').create 16 | Mysqlman::Connection.instance.query('GRANT SELECT ON *.* TO \'tester\'@\'%\' WITH GRANT OPTION') 17 | end 18 | let(:tester) { Mysqlman::User.new(user: 'tester') } 19 | it 'return privs array with grant option' do 20 | expect(tester.privs.fetch).to eq [ 21 | { schema: nil, table: nil, type: 'SELECT' }, 22 | { schema: nil, table: nil, type: 'GRANT OPTION' } 23 | ] 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/privs_util.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Mysqlman::PrivsUtil do 2 | describe '.all' do 3 | let(:expected) do 4 | [ 5 | { schema: 'test_schema', table: 'test_table', type: 'ALTER' }, 6 | { schema: 'test_schema', table: 'test_table', type: 'CREATE VIEW' }, 7 | { schema: 'test_schema', table: 'test_table', type: 'CREATE' }, 8 | { schema: 'test_schema', table: 'test_table', type: 'DELETE' }, 9 | { schema: 'test_schema', table: 'test_table', type: 'DROP' }, 10 | { schema: 'test_schema', table: 'test_table', type: 'INDEX' }, 11 | { schema: 'test_schema', table: 'test_table', type: 'INSERT' }, 12 | { schema: 'test_schema', table: 'test_table', type: 'SELECT' }, 13 | { schema: 'test_schema', table: 'test_table', type: 'SHOW VIEW' }, 14 | { schema: 'test_schema', table: 'test_table', type: 'TRIGGER' }, 15 | { schema: 'test_schema', table: 'test_table', type: 'UPDATE' } 16 | ] 17 | end 18 | context 'do not include grant option' do 19 | it 'all privileges of target lebel' do 20 | expect(Mysqlman::Privs.all('test_schema', 'test_table', false)).to eq expected 21 | end 22 | end 23 | context 'include grant option' do 24 | let(:grant_option) { { schema: 'test_schema', table: 'test_table', type: 'GRANT OPTION' } } 25 | it 'all privileges of target lebel with grant option' do 26 | expect(Mysqlman::Privs.all('test_schema', 'test_table', true)).to eq expected.push(grant_option) 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/role_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Mysqlman::Role do 2 | describe '#privs' do 3 | let(:role) { Mysqlman::Role.new('tester', config) } 4 | context 'only global privs' do 5 | let(:config) do 6 | { 'global' => %w[select update] } 7 | end 8 | let(:expected_privs) do 9 | [ 10 | { schema: nil, table: nil, type: 'SELECT' }, 11 | { schema: nil, table: nil, type: 'UPDATE' } 12 | ] 13 | end 14 | it 'return correct privs' do 15 | expect(role.privs).to eq expected_privs 16 | end 17 | end 18 | context 'multiple levels privs' do 19 | let(:config) do 20 | { 21 | 'global' => ['create user', 'select'], 22 | 'schema' => { 'mysql' => ['update'] } 23 | } 24 | end 25 | let(:expected_privs) do 26 | [ 27 | { schema: nil, table: nil, type: 'CREATE USER' }, 28 | { schema: nil, table: nil, type: 'SELECT' }, 29 | { schema: 'mysql', table: nil, type: 'UPDATE' } 30 | ] 31 | end 32 | it 'return correct privs' do 33 | expect(role.privs).to eq expected_privs 34 | end 35 | end 36 | context 'include all priv' do 37 | let(:config) do 38 | { 'table' => 39 | { 'mysql' => { 'user' => ['all'] } } } 40 | end 41 | let(:expected_privs) do 42 | [ 43 | { schema: 'mysql', table: 'user', type: 'ALTER' }, 44 | { schema: 'mysql', table: 'user', type: 'CREATE VIEW' }, 45 | { schema: 'mysql', table: 'user', type: 'CREATE' }, 46 | { schema: 'mysql', table: 'user', type: 'DELETE' }, 47 | { schema: 'mysql', table: 'user', type: 'DROP' }, 48 | { schema: 'mysql', table: 'user', type: 'INDEX' }, 49 | { schema: 'mysql', table: 'user', type: 'INSERT' }, 50 | { schema: 'mysql', table: 'user', type: 'SELECT' }, 51 | { schema: 'mysql', table: 'user', type: 'SHOW VIEW' }, 52 | { schema: 'mysql', table: 'user', type: 'TRIGGER' }, 53 | { schema: 'mysql', table: 'user', type: 'UPDATE' } 54 | ] 55 | end 56 | it 'return correct privs' do 57 | expect(role.privs).to eq expected_privs 58 | end 59 | end 60 | 61 | describe '.all' do 62 | before do 63 | allow(YAML).to receive(:load_file).and_return(configs) 64 | end 65 | context 'single role in one file' do 66 | let(:configs) do 67 | { 68 | 'tester' => { 'global' => %w[select update] } 69 | } 70 | end 71 | it 'return all role instance' do 72 | expect(Mysqlman::Role.all.map(&:name)).to eq ['tester'] 73 | end 74 | end 75 | context 'multiple roles in one file' do 76 | let(:configs) do 77 | { 78 | 'tester' => { 'global' => %w[select update] }, 79 | 'tester_alt' => { 'global' => %w[select update] } 80 | } 81 | end 82 | it 'return all role instance' do 83 | expect(Mysqlman::Role.all.map(&:name)).to eq %w[tester tester_alt] 84 | end 85 | end 86 | end 87 | 88 | describe '.find' do 89 | before do 90 | allow(YAML).to receive(:load_file).and_return(configs) 91 | end 92 | let(:configs) do 93 | { 94 | 'tester' => { 'global' => %w[select update] }, 95 | 'tester_alt' => { 'global' => %w[select update] } 96 | } 97 | end 98 | it 'return the role instance' do 99 | expect(Mysqlman::Role.find('tester').name).to eq 'tester' 100 | end 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | require 'mysqlman' 3 | require 'pry' 4 | 5 | RSpec.configure do |config| 6 | # Enable flags like --only-failures and --next-failure 7 | config.example_status_persistence_file_path = '.rspec_status' 8 | 9 | # Disable RSpec exposing methods globally on `Module` and `main` 10 | config.disable_monkey_patching! 11 | 12 | config.expect_with :rspec do |c| 13 | c.syntax = :expect 14 | end 15 | 16 | unmanaged_users = [] 17 | config.before do 18 | unmanaged_users = Mysqlman::User.all.map(&:name_with_host) 19 | end 20 | config.after do 21 | all_users = Mysqlman::User.all.map(&:name_with_host) 22 | created_users = (all_users - unmanaged_users).map do |user| 23 | Mysqlman::User.new(user: user['user'], host: user['host']) 24 | end 25 | created_users.map(&:drop) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /spec/user_spec.rb: -------------------------------------------------------------------------------- 1 | RSpec.describe Mysqlman::User do 2 | describe '#name_with_host' do 3 | it 'return hash includes name and host' do 4 | expect(Mysqlman::User.new(user: 'root').name_with_host).to eq('user' => 'root', 'host' => '%') 5 | end 6 | end 7 | 8 | describe '#exists?' do 9 | context 'when the user exists' do 10 | it 'return true' do 11 | expect(Mysqlman::User.new(user: 'root').exists?).to eq true 12 | end 13 | end 14 | context 'when the user does not exist' do 15 | it 'return false' do 16 | expect(Mysqlman::User.new(user: 'wrong_user').exists?).to eq false 17 | end 18 | end 19 | end 20 | 21 | describe '#create' do 22 | let(:user) { Mysqlman::User.new(user: 'before_create') } 23 | context 'no debug mode' do 24 | it 'created user' do 25 | expect(user.create.exists?).to eq true 26 | end 27 | end 28 | context 'debug mode' do 29 | it 'did not create user' do 30 | expect(user.create(true).exists?).to eq false 31 | end 32 | end 33 | end 34 | 35 | describe '#drop' do 36 | let(:user) { Mysqlman::User.new(user: 'before_drop').create } 37 | context 'no debug mode' do 38 | it 'droped user' do 39 | user.drop 40 | expect(user.exists?).to eq false 41 | end 42 | end 43 | context 'debug mode' do 44 | it 'did not drop user' do 45 | user.drop(true) 46 | expect(user.exists?).to eq true 47 | end 48 | end 49 | end 50 | 51 | describe '.all' do 52 | let(:expected_names) do 53 | Mysqlman::Connection.instance.query('SELECT Host, User FROM mysql.user').map do |row| 54 | { 'user' => row['User'], 'host' => row['Host'] } 55 | end 56 | end 57 | it 'return all users instance' do 58 | expect(Mysqlman::User.all.map(&:name_with_host)).to eq expected_names 59 | end 60 | end 61 | 62 | describe '.find' do 63 | let(:expected_user) { { 'user' => 'root', 'host' => '%' } } 64 | it 'return the user instance' do 65 | expect(Mysqlman::User.find('root').name_with_host).to eq expected_user 66 | end 67 | end 68 | end 69 | --------------------------------------------------------------------------------