role_plays 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.
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "rspec/core/rake_task"
5
+
6
+ RSpec::Core::RakeTask.new(:spec)
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[spec rubocop]
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ require_relative "types"
6
+
7
+ module RolePlays
8
+ # Base of the value objects a policy is built from. They are values on purpose: a declaration is
9
+ # read many times and changed by no one, so extending a role returns a new struct rather than
10
+ # mutating the one another policy may have been given.
11
+ class BaseStruct < Dry::Struct
12
+ transform_keys(&:to_sym)
13
+ end
14
+ end
@@ -0,0 +1,392 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "base_struct"
4
+ require_relative "types"
5
+
6
+ module RolePlays
7
+ # Declarative, role based authorization DSL.
8
+ #
9
+ # class OrderPolicy
10
+ # include RolePlays::Mixin
11
+ #
12
+ # context :user, :order, :order_relation
13
+ #
14
+ # role :user do
15
+ # action :create, -> { true }
16
+ # action :destroy do
17
+ # order.user_id == user.id
18
+ # end
19
+ # action :edit do
20
+ # order.user_id == user.id && order.completed?
21
+ # end
22
+ #
23
+ # permitted_attributes %i[title description]
24
+ # permitted_attributes :list, %i[page per_page]
25
+ # permitted_attributes(:create) do
26
+ # %i[title description] + (order ? [:user_id] : [])
27
+ # end
28
+ #
29
+ # scope :list, -> { order_relation.where(sent: true) }
30
+ # scope -> { order_relation.where(user_id: user.id) }
31
+ # end
32
+ #
33
+ # # Roles listed together share the block, each one getting the same declarations
34
+ # role %i[user access_code] do
35
+ # action :list, -> { true }
36
+ #
37
+ # scope -> { order_relation.where(user_id: user.id) }
38
+ # end
39
+ #
40
+ # role :admin do
41
+ # end
42
+ #
43
+ # role :any do
44
+ # action :list, -> { true }
45
+ # end
46
+ # end
47
+ #
48
+ # policy = OrderPolicy.new(role: :user, user: current_user, order: order,
49
+ # order_relation: Order.completed)
50
+ #
51
+ # policy.can?(:destroy) # => true / false
52
+ # policy.permitted_attributes(:list) # => %i[page per_page]
53
+ # policy.scope(:list) # => Order.completed.where(sent: true)
54
+ #
55
+ # Every declared action also gets a `can_<action>?` predicate — `can_create?`, `can_destroy?` — so
56
+ # a policy can replace a hand written one without touching its callers.
57
+ #
58
+ # `role:` is the only argument the policy asks for. Every other keyword is arbitrary: it is kept as
59
+ # context and answered as a reader, so a policy is given what it actually talks about — `user`,
60
+ # `order`, `order_relation` — rather than a fixed record/relation/options triple. `context`
61
+ # declares those names, which makes them optional: a declared name reads as `nil` when the caller
62
+ # leaves it out, while an undeclared one raises, so a typo in a handler is not read as `nil`.
63
+ #
64
+ # The role is a symbol such as :user or :provider_location, selected from the authenticated user by
65
+ # the caller — the policy is told which role it answers for instead of resolving it from a user
66
+ # itself, so the same rules answer for a request, a background job or a spec.
67
+ #
68
+ # Roles are stored per class as RolePlays::Mixin::Role structs. An action is looked up on the
69
+ # current role first and falls back to the :any role, so shared permissions can be declared once.
70
+ # Unknown role/action pairs are denied. Nothing is inherited: a policy declares the roles it
71
+ # answers for, and a role shared between policies is passed around as a Role struct built with
72
+ # RoleBuilder.
73
+ #
74
+ # Permitted attributes and scopes are declared the same way, keyed by a label (:default when
75
+ # omitted) and resolved with the same role/:any fallback. An unknown attributes label yields an
76
+ # empty list, an unknown scope label yields `nil` — narrowing a relation to nothing is left to the
77
+ # caller, the only side that knows which relation the policy was built around.
78
+ #
79
+ # A policy is built once and never mutated afterwards. Action, attribute and scope bodies take no
80
+ # arguments and are evaluated against the policy instance, which gives them access to +role+, to
81
+ # every context keyword and to any helper method defined on the policy class itself.
82
+ # The DSL is one module on purpose — the declarations, the lookups and the readers they feed are
83
+ # one contract, so it is read top to bottom rather than split across files.
84
+ module Mixin
85
+ # Role every other role falls back to for actions it does not declare itself.
86
+ ANY_ROLE = :any
87
+
88
+ # Label a `permitted_attributes` declaration is filed under when none is given.
89
+ DEFAULT_ATTRIBUTES_LABEL = :default
90
+
91
+ # Label a `scope` declaration is filed under when none is given.
92
+ DEFAULT_SCOPE_LABEL = :default
93
+
94
+ # Marks the modules holding the readers `context` generates, so a keyword named after one of
95
+ # them is recognised as declared instead of as shadowing a method of the policy.
96
+ module ContextReaders; end
97
+
98
+ # What one role may do: the actions it declares, the attributes it may submit per label and the
99
+ # scopes it may read per label. Built by RoleBuilder from a `role` block, or by hand to share a
100
+ # role between policies.
101
+ class Role < BaseStruct
102
+ attribute :role, Types::Strict::Symbol
103
+ attribute :actions, Types::Hash.map(Types::Strict::Symbol, Types.Interface(:call)).default({}.freeze)
104
+ attribute :permitted_attributes,
105
+ Types::Hash.map(Types::Strict::Symbol, Types.Interface(:call)).default({}.freeze)
106
+ attribute :scopes, Types::Hash.map(Types::Strict::Symbol, Types.Interface(:call)).default({}.freeze)
107
+
108
+ def action_for(name)
109
+ actions[name]
110
+ end
111
+
112
+ def permitted_attributes_for(label)
113
+ permitted_attributes[label]
114
+ end
115
+
116
+ def scope_for(label)
117
+ scopes[label]
118
+ end
119
+
120
+ # Later declarations win, so a policy can extend or override a role it was given
121
+ def merge(other)
122
+ new(actions: actions.merge(other.actions),
123
+ permitted_attributes: permitted_attributes.merge(other.permitted_attributes),
124
+ scopes: scopes.merge(other.scopes))
125
+ end
126
+ end
127
+
128
+ # Collects `action`, `permitted_attributes` and `scope` declarations of a single `role` block
129
+ class RoleBuilder
130
+ def self.build(role, &block)
131
+ builder = new
132
+ builder.instance_eval(&block) if block
133
+
134
+ Role.new(role: role.to_sym, actions: builder.actions, permitted_attributes: builder.attributes,
135
+ scopes: builder.scopes)
136
+ end
137
+
138
+ attr_reader :actions, :attributes, :scopes
139
+
140
+ def initialize
141
+ @actions = {}
142
+ @attributes = {}
143
+ @scopes = {}
144
+ end
145
+
146
+ def action(name, handler = nil, &block)
147
+ callable = handler || block
148
+ raise ArgumentError, "action :#{name} requires a lambda or a block" if callable.nil?
149
+ raise ArgumentError, "action :#{name} handler must not take required arguments" if callable.arity.positive?
150
+
151
+ @actions[name.to_sym] = callable
152
+ end
153
+
154
+ # Declares the attributes a role may submit, keyed by label (:default when omitted).
155
+ # The attributes can be listed literally or computed by a lambda or a block:
156
+ #
157
+ # permitted_attributes %i[title description]
158
+ # permitted_attributes :list, %i[page per_page]
159
+ # permitted_attributes(:create) { own_order? ? %i[title user_id] : %i[title] }
160
+ def permitted_attributes(label = DEFAULT_ATTRIBUTES_LABEL, attributes = nil, &block)
161
+ unless label.is_a?(Symbol)
162
+ attributes = label
163
+ label = DEFAULT_ATTRIBUTES_LABEL
164
+ end
165
+
166
+ attributes ||= block
167
+ raise ArgumentError, "permitted_attributes :#{label} requires a list, a lambda or a block" if attributes.nil?
168
+
169
+ if attributes.respond_to?(:call) && attributes.arity.positive?
170
+ raise ArgumentError, "permitted_attributes :#{label} handler must not take required arguments"
171
+ end
172
+
173
+ @attributes[label.to_sym] = attributes.respond_to?(:call) ? attributes : -> { attributes }
174
+ end
175
+
176
+ # Declares how a role narrows a relation, keyed by a label (:default when omitted).
177
+ # The body reads the keyword the policy carries its relation in:
178
+ #
179
+ # scope -> { order_relation.where(user_id: user.id) }
180
+ # scope :list, -> { order_relation.where(sent: true) }
181
+ # scope(:report) { own_orders.where(created_at: period) }
182
+ def scope(label = DEFAULT_SCOPE_LABEL, handler = nil, &block)
183
+ unless label.is_a?(Symbol)
184
+ handler = label
185
+ label = DEFAULT_SCOPE_LABEL
186
+ end
187
+
188
+ handler ||= block
189
+ raise ArgumentError, "scope :#{label} requires a lambda or a block" if handler.nil?
190
+ raise ArgumentError, "scope :#{label} handler must not take required arguments" if handler.arity.positive?
191
+
192
+ @scopes[label.to_sym] = handler
193
+ end
194
+ end
195
+
196
+ def self.included(base)
197
+ base.extend(ClassMethods)
198
+ end
199
+
200
+ # The DSL a policy class answers, and the lookups it resolves a declaration with
201
+ module ClassMethods
202
+ # Declares one or more roles, either with a block of actions or with a prebuilt Role struct.
203
+ # Names given as a list or as an array share the block, which is evaluated once and filed
204
+ # under each of them, so roles with identical permissions are declared together:
205
+ #
206
+ # role :user do ... end
207
+ # role %i[user access_code] do ... end
208
+ def role(*roles, &block)
209
+ roles = roles.flatten
210
+ raise ArgumentError, "role requires at least one role name" if roles.empty?
211
+
212
+ shared = nil
213
+ roles.each do |role_or_name|
214
+ next declare_role(role_or_name) if role_or_name.is_a?(Role)
215
+
216
+ shared ||= RoleBuilder.build(role_or_name, &block)
217
+ declare_role(shared.new(role: role_or_name.to_sym))
218
+ end
219
+ end
220
+
221
+ # Files a Role struct under its name, merging it into an already declared role of the
222
+ # same name so a later declaration extends or overrides the earlier one
223
+ def declare_role(role)
224
+ declared = policy_roles[role.role]
225
+
226
+ policy_roles[role.role] = declared ? declared.merge(role) : role
227
+ define_action_predicates(role.actions.keys)
228
+ end
229
+
230
+ # The roles this policy declares — nothing is inherited, so a role shared with another policy
231
+ # is composed in as a Role struct rather than picked up from a parent class
232
+ def policy_roles
233
+ @policy_roles ||= {}
234
+ end
235
+
236
+ def action_handler(role, action)
237
+ policy_roles[role]&.action_for(action) || policy_roles[ANY_ROLE]&.action_for(action)
238
+ end
239
+
240
+ def permitted_attributes_handler(role, label)
241
+ policy_roles[role]&.permitted_attributes_for(label) || policy_roles[ANY_ROLE]&.permitted_attributes_for(label)
242
+ end
243
+
244
+ def scope_handler(role, label)
245
+ policy_roles[role]&.scope_for(label) || policy_roles[ANY_ROLE]&.scope_for(label)
246
+ end
247
+
248
+ # Defines a `can_<action>?` predicate per declared action, so a policy answers the same
249
+ # `can_create?` / `can_destroy?` messages a hand written one does:
250
+ #
251
+ # action :create # => can_create? == can?(:create)
252
+ # action :destroy # => can_destroy? == can?(:destroy)
253
+ #
254
+ # Called for every `role` declaration, so the predicates cover the actions of all roles —
255
+ # each one still resolves the handler for the current role at call time. The methods live on
256
+ # a module included into the policy, so a predicate written by hand on the class always wins.
257
+ def define_action_predicates(*actions)
258
+ actions.flatten.each do |action|
259
+ action = action.to_sym
260
+ predicate = :"can_#{action}?"
261
+ next if action_predicates.method_defined?(predicate)
262
+
263
+ action_predicates.define_method(predicate) { can?(action) }
264
+ end
265
+ end
266
+
267
+ # Module holding the generated predicates of this class, included on first use
268
+ def action_predicates
269
+ @action_predicates ||= Module.new.tap { |generated| include generated }
270
+ end
271
+
272
+ # Declares the keywords the policy is built with, giving each one a reader that answers `nil`
273
+ # when the caller left it out, so a handler can treat it as optional:
274
+ #
275
+ # context :user, :order, :order_relation
276
+ #
277
+ # action(:create) { %i[title] + (order ? [:user_id] : []) }
278
+ #
279
+ # Declaring is optional — any keyword `new` is given is readable by name anyway. What the
280
+ # declaration adds is the `nil` for a keyword that was not passed: an undeclared name always
281
+ # raises, so a typo in a handler cannot quietly answer `nil`.
282
+ def context(*names)
283
+ names.flatten.each do |name|
284
+ name = name.to_sym
285
+ policy_context_keys << name unless policy_context_keys.include?(name)
286
+ next if method_defined?(name) || private_method_defined?(name)
287
+
288
+ context_readers.define_method(name) { context[name] }
289
+ end
290
+ end
291
+
292
+ # The names this policy declares with `context`
293
+ def policy_context_keys
294
+ @policy_context_keys ||= []
295
+ end
296
+
297
+ # Module holding the generated context readers of this class, included on first use
298
+ def context_readers
299
+ @context_readers ||= Module.new.tap do |generated|
300
+ generated.extend(ContextReaders)
301
+ include generated
302
+ end
303
+ end
304
+
305
+ # Whether a keyword of this name would be unreachable because the policy answers it already.
306
+ # A reader generated by `context` does not count — answering it is what it is there for.
307
+ def reserved_context_name?(name)
308
+ return false unless method_defined?(name) || private_method_defined?(name)
309
+
310
+ !instance_method(name).owner.is_a?(ContextReaders)
311
+ end
312
+ end
313
+
314
+ attr_reader :role, :context
315
+
316
+ # The role is the name a `role` declaration was filed under, `nil` standing for no role at all,
317
+ # which leaves only the :any declarations. It is the only argument the policy asks for — every
318
+ # other keyword is kept as context and read back by name:
319
+ #
320
+ # OrderPolicy.new(role: :user, user: current_user, order: order, order_relation: Order.all)
321
+ def initialize(role:, **context)
322
+ reject_shadowing_context!(context)
323
+
324
+ @role = role&.to_sym
325
+ @context = context.freeze
326
+ end
327
+
328
+ def can?(action)
329
+ handler = self.class.action_handler(role, action.to_sym)
330
+ return false if handler.nil?
331
+
332
+ !!instance_exec(&handler)
333
+ end
334
+
335
+ def cannot?(action)
336
+ !can?(action)
337
+ end
338
+
339
+ # Attributes the current role may submit for the given label, always as an array
340
+ # ready to be handed to ActionController::Parameters#permit
341
+ def permitted_attributes(label = DEFAULT_ATTRIBUTES_LABEL)
342
+ handler = self.class.permitted_attributes_handler(role, label.to_sym)
343
+ return [] if handler.nil?
344
+
345
+ wrap_attributes(instance_exec(&handler))
346
+ end
347
+
348
+ # The relation narrowed to what the current role may see under the given label, or `nil` when
349
+ # neither that role nor :any declares the label. The policy carries its relation in a keyword of
350
+ # its own naming, so saying "nothing is visible" is left to the caller:
351
+ #
352
+ # OrderPolicy.new(role:, order_relation: Order.all).scope(:list) || Order.none
353
+ def scope(label = DEFAULT_SCOPE_LABEL)
354
+ handler = self.class.scope_handler(role, label.to_sym)
355
+ return nil if handler.nil?
356
+
357
+ instance_exec(&handler)
358
+ end
359
+
360
+ private
361
+
362
+ # Reads a context keyword — `new(role: :user, order:)` answers `order` — so handlers can name
363
+ # what they need. An unknown name still raises NameError, so a typo is not read as `nil`.
364
+ def method_missing(name, *args)
365
+ return super unless args.empty? && context.key?(name)
366
+
367
+ context[name]
368
+ end
369
+
370
+ def respond_to_missing?(name, include_private = false)
371
+ context.key?(name.to_sym) || super
372
+ end
373
+
374
+ # A context keyword only reaches a handler through its reader, so one named after a method the
375
+ # policy already answers — `can?`, `context`, a helper of its own — would be silently unreadable
376
+ def reject_shadowing_context!(context)
377
+ shadowed = context.keys.select { |key| self.class.reserved_context_name?(key) }
378
+ return if shadowed.empty?
379
+
380
+ raise ArgumentError, "#{self.class} already defines #{shadowed.map { ":#{_1}" }.join(", ")}, " \
381
+ "so the value passed under that name could never be read"
382
+ end
383
+
384
+ # A declared list is handed straight to `permit`, so a single attribute — or a nested hash —
385
+ # reads as the one element list it stands for rather than being splatted
386
+ def wrap_attributes(attributes)
387
+ return [] if attributes.nil?
388
+
389
+ attributes.is_a?(::Array) ? attributes : [attributes]
390
+ end
391
+ end
392
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-types"
4
+
5
+ module RolePlays
6
+ # The types the structs of this gem are built from
7
+ module Types
8
+ include Dry.Types()
9
+ end
10
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RolePlays
4
+ VERSION = "0.1.0"
5
+ end
data/lib/role_plays.rb ADDED
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "role_plays/version"
4
+ require_relative "role_plays/base_struct"
5
+ require_relative "role_plays/types"
6
+ require_relative "role_plays/mixin"
7
+
8
+ module RolePlays
9
+ class Error < StandardError; end
10
+ end
@@ -0,0 +1,4 @@
1
+ module RolePlays
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,74 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: role_plays
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Aliexandr Andrade
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: dry-struct
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '1.6'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '1.6'
26
+ description: 'RolePlays turns a policy into one readable class per resource, built
27
+ and asked explicitly the way Pundit and Action Policy policies are, with its rules
28
+ declared rather than defined method by method as in CanCanCan: every role declares
29
+ the actions it may take, the attributes it may submit and the scopes it may read,
30
+ all resolved with a shared :any fallback. The policy is told the role name rather
31
+ than a user, so the same rules answer for a request, a background job or a spec.'
32
+ email:
33
+ - veimar.94@gmail.com
34
+ executables: []
35
+ extensions: []
36
+ extra_rdoc_files: []
37
+ files:
38
+ - CHANGELOG.md
39
+ - LICENSE.txt
40
+ - README.md
41
+ - Rakefile
42
+ - lib/role_plays.rb
43
+ - lib/role_plays/base_struct.rb
44
+ - lib/role_plays/mixin.rb
45
+ - lib/role_plays/types.rb
46
+ - lib/role_plays/version.rb
47
+ - sig/role_plays.rbs
48
+ homepage: https://github.com/Alexander-Andrade/role_plays
49
+ licenses:
50
+ - MIT
51
+ metadata:
52
+ allowed_push_host: https://rubygems.org
53
+ homepage_uri: https://github.com/Alexander-Andrade/role_plays
54
+ source_code_uri: https://github.com/Alexander-Andrade/role_plays
55
+ changelog_uri: https://github.com/Alexander-Andrade/role_plays
56
+ rdoc_options: []
57
+ require_paths:
58
+ - lib
59
+ required_ruby_version: !ruby/object:Gem::Requirement
60
+ requirements:
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 3.2.0
64
+ required_rubygems_version: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ requirements: []
70
+ rubygems_version: 4.0.8
71
+ specification_version: 4
72
+ summary: Role based authorization policies, one per resource as in Pundit or Action
73
+ Policy, declared with a DSL in the spirit of CanCanCan.
74
+ test_files: []