bullet_train-roles 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "lib/bullet_train//roles/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "bullet_train-roles"
7
+ spec.version = Roles::VERSION
8
+ spec.authors = ["Prabin Poudel", "Andrew Culver"]
9
+ spec.email = %w[andrew.culver@gmail.com]
10
+
11
+ spec.summary = "Yaml-backed ApplicationHash for CanCan Roles"
12
+ spec.description = "Yaml-backed ApplicationHash for CanCan Roles"
13
+ spec.homepage = "https://github.com/bullet-train-co/bullet_train-roles"
14
+ spec.license = "MIT"
15
+ spec.required_ruby_version = Gem::Requirement.new(">= 2.4.0")
16
+
17
+ spec.metadata["homepage_uri"] = spec.homepage
18
+ spec.metadata["source_code_uri"] = "https://github.com/bullet-train-co/bullet_train-roles"
19
+ spec.metadata["changelog_uri"] = "https://github.com/bullet-train-co/bullet_train-roles/blob/main/CHANGELOG.md"
20
+
21
+ # Specify which files should be added to the gem when it is released.
22
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
23
+ spec.files = Dir.chdir(File.expand_path(__dir__)) do
24
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{\A(?:test|spec|features)/}) }
25
+ end
26
+
27
+ spec.require_paths = ["lib"]
28
+
29
+ spec.add_development_dependency "byebug", "~> 11.1.0"
30
+ spec.add_development_dependency "factory_bot_rails", "~> 6.2.0"
31
+ spec.add_development_dependency "knapsack_pro", "~> 3.1.0"
32
+ spec.add_development_dependency "minitest", "~> 5.0"
33
+ spec.add_development_dependency "pg", "~> 1.2.0"
34
+ spec.add_development_dependency "rails", "~> 7.0.0"
35
+ spec.add_development_dependency "rake", "~> 13.0"
36
+ spec.add_development_dependency "standard", "~> 1.5.0"
37
+
38
+ spec.add_runtime_dependency "active_hash"
39
+ spec.add_runtime_dependency "activesupport"
40
+ spec.add_runtime_dependency "cancancan"
41
+ # For more information and examples about making a new gem, checkout our
42
+ # guide at: https://bundler.io/guides/creating_gem.html
43
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roles
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "roles/version"
4
+
5
+ require_relative "../models/role"
6
+ require_relative "../roles/permit"
7
+ require_relative "../roles/support"
8
+
9
+ module Roles
10
+ class Error < StandardError; end
11
+ end
@@ -0,0 +1,11 @@
1
+ Description:
2
+ Install required configuration
3
+
4
+ Example:
5
+ rails generate bullet_train:roles:install
6
+
7
+ This will:
8
+ create config/models/roles.yml
9
+ create migration file to add role_ids to top level model
10
+ add include Role::Support to top level model
11
+ add permit line to app/models/ability.rb
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+ require "rails/generators"
5
+
6
+ module BulletTrain
7
+ module Roles
8
+ class InstallGenerator < Rails::Generators::Base
9
+ source_root File.expand_path("templates", __dir__)
10
+
11
+ def add_roles_config_file
12
+ puts("Creating role.yml inside config/models with default roles\n")
13
+
14
+ copy_file "roles.yml", "config/models/roles.yml"
15
+
16
+ puts("Success 🎉🎉\n\n")
17
+ end
18
+
19
+ def configure_models
20
+ json_data_type_identifier = find_json_data_type_identifier
21
+
22
+ top_level_model = ask("Which model do you want to use as a top-level model that represents Membership? (Defaults to Membership)") || "Membership"
23
+
24
+ generate_migration_to_add_role_ids(top_level_model, json_data_type_identifier)
25
+
26
+ include_role_support_in_top_level_model(top_level_model)
27
+
28
+ associated_model = ask("Which model/association of #{top_level_model} do you consider to be the Team? (Default to Team)") || "Team"
29
+
30
+ # Asks for a model we can setup some default permissions for.
31
+ # TODO: follow back Andrew on question about this in Slack
32
+
33
+ add_permit_to_ability_model(top_level_model, associated_model)
34
+ end
35
+
36
+ private
37
+
38
+ def ask(question)
39
+ puts(question)
40
+
41
+ answer = gets.chomp
42
+
43
+ return if answer.blank?
44
+
45
+ answer
46
+ end
47
+
48
+ def db_adapter
49
+ allowed_adapter_types = %w[mysql sqlite postgresql]
50
+
51
+ adapter_name = ActiveRecord::Base.connection.adapter_name.downcase
52
+
53
+ if allowed_adapter_types.exclude?(adapter_name)
54
+ raise NotImplementedError, "'#{adapter_name}' is not supported!"
55
+ end
56
+
57
+ adapter_name
58
+ end
59
+
60
+ def find_json_data_type_identifier
61
+ adapter_name = db_adapter
62
+
63
+ case adapter_name
64
+ when "postgresql"
65
+ "jsonb"
66
+ else
67
+ "json"
68
+ end
69
+ end
70
+
71
+ def add_in_file(file_location, line_to_match, content_to_add)
72
+ update_file_content = []
73
+
74
+ file_lines = File.readlines(file_location)
75
+
76
+ file_lines.each do |line|
77
+ line_pattern = line_to_match
78
+ updated_line = line
79
+
80
+ if line.include?(line_pattern)
81
+ trimmed_line = line.tr("\n", "")
82
+
83
+ updated_line = "#{trimmed_line}#{content_to_add}"
84
+ end
85
+
86
+ update_file_content.push(updated_line)
87
+ end
88
+
89
+ File.write(file_location, update_file_content.join)
90
+ end
91
+
92
+ def add_default_value_to_migration(file_name, table_name)
93
+ file_location = Dir["db/migrate/*_#{file_name}.rb"].last
94
+ line_to_match = "add_column :#{table_name.downcase}, :role_ids"
95
+ content_to_add = ", default: []\n"
96
+
97
+ add_in_file(file_location, line_to_match, content_to_add)
98
+ end
99
+
100
+ def migration_file_exists?(file_name)
101
+ file_location = Dir["db/migrate/*_#{file_name}.rb"].last
102
+
103
+ file_location.present?
104
+ end
105
+
106
+ def file_already_exists(message)
107
+ puts(message)
108
+ end
109
+
110
+ def generate_migration_to_add_role_ids(top_level_model, json_data_type_identifier)
111
+ top_level_model_table_name = top_level_model.underscore.pluralize
112
+
113
+ migration_file_name = "add_role_ids_to_#{top_level_model_table_name}"
114
+
115
+ return file_already_exists("Migration file already exists!!\n\n") if migration_file_exists?(migration_file_name)
116
+
117
+ puts("Generating migration to add role_ids to #{top_level_model}")
118
+
119
+ generate "migration", "#{migration_file_name} role_ids:#{json_data_type_identifier}"
120
+
121
+ add_default_value_to_migration(migration_file_name, top_level_model_table_name)
122
+
123
+ puts("Success 🎉🎉\n\n")
124
+ end
125
+
126
+ def line_exists_in_file?(file_location, line_to_compare)
127
+ file_lines = File.readlines(file_location)
128
+
129
+ file_lines.join.include?(line_to_compare)
130
+ end
131
+
132
+ def line_already_exists(message)
133
+ puts(message)
134
+ end
135
+
136
+ def remove_new_lines_and_spaces(line)
137
+ line.tr("\n", "").strip
138
+ end
139
+
140
+ def include_role_support_in_top_level_model(model_name)
141
+ # converts 👇
142
+ # Membership to membership
143
+ # Admin::Membership to admin/membership
144
+ # TeamMember to team_member
145
+ file_location = "app/models/#{model_name.underscore}.rb"
146
+ line_to_match = "class #{model_name.classify} < ApplicationRecord"
147
+ content_to_add = "\n include Roles::Support\n"
148
+
149
+ puts("Adding 'include Roles::Support' to #{model_name}\n\n")
150
+
151
+ if line_exists_in_file?(file_location, content_to_add)
152
+ message = "'#{remove_new_lines_and_spaces(content_to_add)}' already exists in #{model_name}!!\n\n"
153
+
154
+ return line_already_exists(message)
155
+ end
156
+
157
+ add_in_file(file_location, line_to_match, content_to_add)
158
+
159
+ puts("Success 🎉🎉\n\n")
160
+ end
161
+
162
+ def add_permit_to_ability_model(top_level_model, associated_model)
163
+ # TODO: need to make this more smart e.g.
164
+ # 1. should know what parameter is used for user e.g. def initialize(user)
165
+ # 2. what if code doesn't include "if user.present?", maybe we need to handle that, consult with Andrew
166
+ file_location = "app/models/ability.rb"
167
+ line_to_match = "if user.present?"
168
+ content_to_add = "\n permit user, through: :#{top_level_model.downcase.pluralize}, parent: :#{associated_model.downcase}\n"
169
+
170
+ puts("Adding 'permit user, through: :#{top_level_model.downcase}, parent: :#{associated_model.downcase}' to #{associated_model}\n\n")
171
+
172
+ if line_exists_in_file?(file_location, content_to_add)
173
+ message = "#{remove_new_lines_and_spaces(content_to_add)} already exists in #{associated_model}!!\n\n"
174
+
175
+ return line_already_exists(message)
176
+ end
177
+
178
+ add_in_file(file_location, line_to_match, content_to_add)
179
+
180
+ puts("Success 🎉🎉\n\n")
181
+ end
182
+ end
183
+ end
184
+ end
@@ -0,0 +1,15 @@
1
+ default:
2
+ models:
3
+ -
4
+
5
+ editor:
6
+ models:
7
+ -
8
+ manageable_roles:
9
+ - editor
10
+
11
+ admin:
12
+ includes:
13
+ - editor
14
+ manageable_roles:
15
+ - admin
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_hash"
4
+
5
+ class RemovingLastTeamAdminException < RuntimeError; end
6
+
7
+ class Role < ActiveYaml::Base
8
+ include ActiveYaml::Aliases
9
+ set_root_path "config/models"
10
+ set_filename "roles"
11
+
12
+ field :includes, default: []
13
+ field :models, default: []
14
+ field :manageable_roles, default: []
15
+
16
+ def self.admin
17
+ find_by_key("admin")
18
+ end
19
+
20
+ def self.default
21
+ find_by_key("default")
22
+ end
23
+
24
+ def self.includes(role_or_key)
25
+ role_key = role_or_key.is_a?(Role) ? role_or_key.key : role_or_key
26
+
27
+ role = Role.find_by_key(role_key)
28
+
29
+ return Role.all.select(&:assignable?) if role.default?
30
+
31
+ result = []
32
+
33
+ all.each do |role|
34
+ result << role if role.includes.include?(role_key)
35
+ end
36
+
37
+ result
38
+ end
39
+
40
+ def self.assignable
41
+ all.reject(&:default?)
42
+ end
43
+
44
+ def self.find(key)
45
+ all.find { |role| role.key == key }
46
+ end
47
+
48
+ # We don't want to ever use the automatically generated ids from ActiveYaml. These are created based on the order of objects in the yml file
49
+ # so if someone ever changed the order of that file around, they would really mess up the user permissions. Instead, we're using the key attribute.
50
+ def id
51
+ key
52
+ end
53
+
54
+ def included_by
55
+ Role.includes(self)
56
+ end
57
+
58
+ # We need to search for memberships that have this role included directly OR any memberships that have this role _through_ it being included in another role they have
59
+ def key_plus_included_by_keys
60
+ # get direct parent roles
61
+ (included_by.map(&:key_plus_included_by_keys).flatten + [id]).uniq
62
+ end
63
+
64
+ def default?
65
+ key == "default"
66
+ end
67
+
68
+ def admin?
69
+ key == "admin"
70
+ end
71
+
72
+ def included_roles
73
+ default_roles = []
74
+
75
+ default_roles << Role.default unless default?
76
+
77
+ (default_roles + includes.map { |included_key| Role.find_by_key(included_key) }).uniq.compact
78
+ end
79
+
80
+ def manageable_by?(role_or_roles)
81
+ return true if default?
82
+
83
+ roles = role_or_roles.is_a?(Array) ? role_or_roles : [role_or_roles]
84
+
85
+ roles.each do |role|
86
+ return true if role.manageable_roles.include?(key)
87
+
88
+ role.included_roles.each do |included_role|
89
+ return true if manageable_by?([included_role])
90
+ end
91
+ end
92
+
93
+ false
94
+ end
95
+
96
+ def assignable?
97
+ !default?
98
+ end
99
+
100
+ def ability_generator(user, through, parent)
101
+ models.each do |model_name, _|
102
+ ag = AbilityGenerator.new(self, model_name, user, through, parent)
103
+ yield(ag)
104
+ end
105
+ end
106
+
107
+ # The only purpose of this class is to allow developers to do things like:
108
+ # Membership.first.roles << Role.admin
109
+ # Membership.first.roles.delete Role.admin
110
+ # It basically makes the role_ids column act like a Rails `has_many through` collection - this includes automatically saving changes when you add or remove from the array.
111
+ class Collection < Array
112
+ def initialize(model, ary)
113
+ @model = model
114
+
115
+ super(ary)
116
+ end
117
+
118
+ def <<(role)
119
+ return true if include?(role)
120
+
121
+ role_ids = @model.role_ids
122
+
123
+ role_ids << role.id
124
+
125
+ @model.update(role_ids: role_ids)
126
+ end
127
+
128
+ def delete(role)
129
+ @model.role_ids -= [role.key]
130
+
131
+ @model.save
132
+ end
133
+ end
134
+
135
+ class AbilityGenerator
136
+ attr_reader :model
137
+
138
+ def initialize(role, model_name, user, through, parent_name)
139
+ begin
140
+ @model = model_name.constantize
141
+ rescue NameError
142
+ raise "#{model_name} model is used in `config/models/roles.yml` for the #{role.key} role but is not defined in `app/models`."
143
+ end
144
+
145
+ @role = role
146
+ @ability_data = role.models[model_name]
147
+ @through = through
148
+ @parent = user.send(through).reflect_on_association(parent_name)&.klass
149
+ @parent_ids = user.parent_ids_for(@role, @through, parent_name) if @parent
150
+ end
151
+
152
+ def valid?
153
+ actions.present? && model.present? && condition.present?
154
+ end
155
+
156
+ def actions
157
+ return @actions if @actions
158
+
159
+ actions = (@ability_data["actions"] if @ability_data.is_a?(Hash)) || @ability_data
160
+
161
+ actions = [actions] unless actions.is_a?(Array)
162
+
163
+ @actions = actions.map!(&:to_sym)
164
+ end
165
+
166
+ def possible_parent_associations
167
+ ary = @parent.to_s.split("::").map(&:underscore)
168
+
169
+ possibilities = []
170
+ current = nil
171
+
172
+ until ary.empty?
173
+ current = "#{ary.pop}#{"_" unless current.nil?}#{current}"
174
+ possibilities << current
175
+ end
176
+
177
+ possibilities.map(&:to_sym)
178
+ end
179
+
180
+ def condition
181
+ return @condition if @condition
182
+
183
+ return nil unless @parent_ids
184
+
185
+ if @model == @parent
186
+ return @condition = {id: @parent_ids}
187
+ end
188
+
189
+ parent_association = possible_parent_associations.find { |association| @model.method_defined? association }
190
+
191
+ return nil unless parent_association.present?
192
+
193
+ @condition = {parent_association => {id: @parent_ids}}
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Roles
4
+ module Permit
5
+ def permit(user, through:, parent:, debug: false)
6
+ # Without this, you need to restart the server each time you make changes to the config/models/role.yml file
7
+ Role.reload(true) if Rails.env.development?
8
+
9
+ # When changing permissions during development, you may also want to do this on each request:
10
+ # User.update_all ability_cache: nil if Rails.env.development?
11
+
12
+ output = []
13
+ added_roles = Set.new
14
+
15
+ user.send(through).map(&:roles).flatten.uniq.each do |role|
16
+ unless added_roles.include?(role)
17
+ output << "########### ROLE: #{role.key}"
18
+
19
+ output += add_abilities_for(role, user, through, parent)
20
+
21
+ added_roles << role
22
+ end
23
+
24
+ role.included_roles.each do |included_role|
25
+ unless added_roles.include?(included_role)
26
+ output << "############# INCLUDED ROLE: #{included_role.key}"
27
+
28
+ output += add_abilities_for(included_role, user, through, parent)
29
+ end
30
+ end
31
+ end
32
+
33
+ if debug
34
+ puts "###########################"
35
+ puts "Auto generated `ability.rb` content:"
36
+ puts output
37
+ puts "############################"
38
+ end
39
+ end
40
+
41
+ def add_abilities_for(role, user, through, parent)
42
+ output = []
43
+
44
+ role.ability_generator(user, through, parent) do |ag|
45
+ if ag.valid?
46
+ output << "can #{ag.actions}, #{ag.model}, #{ag.condition}"
47
+
48
+ can(ag.actions, ag.model, ag.condition)
49
+ else
50
+ output << "# #{ag.model} does not respond to #{parent} so we're not going to add an ability for the #{through} context"
51
+ end
52
+ end
53
+
54
+ output
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support"
4
+
5
+ module Roles
6
+ module Support
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def roles_only(*roles)
11
+ @allowed_roles = roles.map(&:to_sym)
12
+ end
13
+
14
+ def assignable_roles
15
+ return Role.assignable if @allowed_roles.nil?
16
+
17
+ Role.assignable.select { |role| @allowed_roles.include?(role.key.to_sym) }
18
+ end
19
+
20
+ # Note default_role is an ActiveRecord core class method so we need to use something else here
21
+ def default_roles
22
+ default_role = Role.default
23
+
24
+ return [default_role] if @allowed_roles.nil?
25
+
26
+ @allowed_roles.include?(default_role.key.to_sym) ? [default_role] : []
27
+ end
28
+ end
29
+
30
+ included do
31
+ validate :validate_roles
32
+
33
+ # This query will return records that have a role "included" in a different role they have.
34
+ # For example, if you do with_roles(editor) it will return admin users if the admin role includes the editor role
35
+ scope :with_roles, ->(roles) { where("#{table_name}.role_ids ?| array[:keys]", keys: roles.map(&:key_plus_included_by_keys).flatten.uniq.map(&:to_s)) }
36
+
37
+ # This query will return roles that include the given role. See with_roles above for details
38
+ scope :with_role, ->(role) { role.nil? ? all : with_roles([role]) }
39
+ scope :viewers, -> { where("#{table_name}.role_ids = ?", [].to_json) }
40
+ scope :editors, -> { with_role(Role.find_by_key("editor")) }
41
+ scope :admins, -> { with_role(Role.find_by_key("admin")) }
42
+
43
+ after_save :invalidate_cache
44
+ after_destroy :invalidate_cache
45
+
46
+ def validate_roles
47
+ self.role_ids = role_ids.select(&:present?)
48
+
49
+ return if @allowed_roles.nil?
50
+
51
+ roles.each do |role|
52
+ errors.add(:roles, :invalid) unless @allowed_roles.include?(role.key.to_sym)
53
+ end
54
+ end
55
+
56
+ def roles
57
+ Role::Collection.new(self, (self.class.default_roles + roles_without_defaults).compact.uniq)
58
+ end
59
+
60
+ def roles=(roles)
61
+ self.role_ids = roles.map(&:key)
62
+ end
63
+
64
+ def assignable_roles
65
+ roles.select(&:assignable?)
66
+ end
67
+
68
+ def roles_without_defaults
69
+ role_ids.map { |role_id| Role.find(role_id) }
70
+ end
71
+
72
+ def manageable_roles
73
+ roles.map(&:manageable_roles).flatten.uniq.map { |role_key| Role.find_by_key(role_key) }
74
+ end
75
+
76
+ def can_manage_role?(role)
77
+ manageable_roles.include?(role)
78
+ end
79
+
80
+ def admin?
81
+ roles.select(&:admin?).any?
82
+ end
83
+
84
+ def invalidate_cache
85
+ user&.invalidate_ability_cache
86
+ end
87
+ end
88
+ end
89
+ end