bullet_train-roles 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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