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/README.md ADDED
@@ -0,0 +1,869 @@
1
+ # RolePlays
2
+
3
+ `RolePlays::Mixin` ([lib/role_plays/mixin.rb](lib/role_plays/mixin.rb)) is a declarative, role based
4
+ authorization DSL. A policy class covers one resource: it declares roles, each role declares actions,
5
+ and every action is a callable that answers a single question: is this allowed? A role also declares
6
+ the attributes it may submit and the scopes it may read.
7
+
8
+ ```ruby
9
+ policy = OrderPolicy.new(role: :user, user: current_user, order: order,
10
+ order_relation: Order.completed)
11
+
12
+ policy.can?(:destroy) # => true / false
13
+ policy.cannot?(:edit) # => !can?(:edit)
14
+
15
+ policy.permitted_attributes # => %i[title description]
16
+ policy.permitted_attributes(:list) # => %i[page per_page]
17
+
18
+ policy.scope(:list) # => a narrowed relation, or nil
19
+ ```
20
+
21
+ `role:` is the only argument the policy asks for. Everything else is arbitrary: each keyword is
22
+ kept as context and answered as a reader, so a policy is given what it actually talks about — see
23
+ [Context](#context).
24
+
25
+ Every declared action also gets a `can_<action>?` predicate, so callers written against a hand
26
+ written policy keep working — see [Action predicates](#action-predicates).
27
+
28
+ The role is a symbol rather than a user, so the policy never reaches into a token, a session or a
29
+ decorator. Deriving the role from whoever is authenticated is the caller's job — see
30
+ [Supplying the role](#supplying-the-role).
31
+
32
+ Everything is denied by default: an unknown role, an unknown action, or a `nil` role all return
33
+ `false` from `can?`, an undeclared attribute label returns `[]`, and an undeclared scope label
34
+ returns `nil`.
35
+
36
+ ## Where it sits
37
+
38
+ RolePlays is **a policy per resource**, the way [Pundit](https://github.com/varvet/pundit) and
39
+ [Action Policy](https://actionpolicy.evilmartians.io) are: `OrderPolicy`, `BoatPolicy`,
40
+ `InvoicePolicy` — one class per thing being authorized, built explicitly at the call site and asked
41
+ about that one thing. There is no global ability object holding every rule in the application, and
42
+ nothing is inferred: the policy you instantiate is the policy that answers.
43
+
44
+ Inside that class the rules are written **in a DSL closer to
45
+ [CanCanCan](https://github.com/CanCanCommunity/cancancan)** than to Pundit's method per action: a
46
+ permission is a declaration, not a method definition, so the ones that are a single expression stay a
47
+ single line.
48
+
49
+ ```ruby
50
+ # Pundit / Action Policy: a method per action, one class per role or a chain of conditionals
51
+ class OrderPolicy < ApplicationPolicy
52
+ def destroy?
53
+ user.admin? || record.user_id == user.id
54
+ end
55
+ end
56
+
57
+ # CanCanCan: declarative rules, but for every resource at once, keyed to the user
58
+ class Ability
59
+ include CanCan::Ability
60
+
61
+ def initialize(user)
62
+ can :destroy, Order, user_id: user.id
63
+ can :destroy, Boat, user_id: user.id
64
+ end
65
+ end
66
+
67
+ # RolePlays: declarative rules like CanCanCan, scoped to one resource like Pundit, grouped by role
68
+ class OrderPolicy
69
+ include RolePlays::Mixin
70
+
71
+ context :current_role, :order, :orders
72
+
73
+ role :user do
74
+ action :destroy, -> { order.user_id == current_role.id }
75
+
76
+ scope :list, -> { orders.where(user_id: current_role.id) }
77
+ end
78
+
79
+ role :admin do
80
+ action :destroy, -> { true }
81
+ end
82
+ end
83
+ ```
84
+
85
+ What each borrowed idea is doing here:
86
+
87
+ * **From Pundit / Action Policy** — the per-resource policy object, instantiated per question, with
88
+ the record (and the relation) handed to it rather than looked up. Scopes live in the same class as
89
+ the actions, so "who may see this" and "what may they see" are read together, and `can_destroy?`
90
+ predicates mean a hand written policy can be swapped for one of these without touching callers.
91
+ * **From CanCanCan** — the declarative rule list and the `can?` / `cannot?` vocabulary. `action`,
92
+ `permitted_attributes` and `scope` are declarations collected at class definition time, so a role's
93
+ permissions are a list to be scanned instead of a wall of method definitions.
94
+ * **Its own part** — the role is a first class name a rule is filed under (with an `:any` fallback),
95
+ not a condition inside a rule; and it is passed in as a symbol, so the policy never reads a user.
96
+ Attributes and scopes are keyed by label, so one policy answers `:create`, `:update` and `:list`
97
+ lists rather than one anonymous list per role.
98
+
99
+ Two deliberate omissions: rules are **not** translated into SQL — a scope is a relation you narrow
100
+ yourself, so there is no `accessible_by` guessing a query from a hash of conditions — and there are
101
+ no controller hooks or `authorize` callbacks. Building the policy and asking it is one line the
102
+ caller writes.
103
+
104
+ ## Installation
105
+
106
+ ```sh
107
+ bundle add role_plays
108
+ ```
109
+
110
+ Or without Bundler:
111
+
112
+ ```sh
113
+ gem install role_plays
114
+ ```
115
+
116
+ Then require it — `require "role_plays"` — and include the mixin in a policy class. Ruby 3.2 or
117
+ newer; the only runtime dependency is [dry-struct](https://dry-rb.org/gems/dry-struct), which the
118
+ role structs are built from. Nothing here is tied to Rails: `permitted_attributes` is a list of
119
+ symbols, and a scope is whatever the relation you passed in returns.
120
+
121
+ ## Anatomy
122
+
123
+ ```ruby
124
+ class OrderPolicy
125
+ include RolePlays::Mixin
126
+
127
+ context :current_role, :order, :order_relation
128
+
129
+ role :user do
130
+ action :create, -> { true } # lambda handler
131
+ action :destroy do # block handler
132
+ order.user_id == current_role.id
133
+ end
134
+
135
+ permitted_attributes %i[title description]
136
+
137
+ scope -> { order_relation.where(user_id: current_role.id) }
138
+ end
139
+
140
+ role :any do # fallback for every other role
141
+ action :list, -> { true }
142
+
143
+ permitted_attributes :list, %i[page per_page]
144
+ end
145
+ end
146
+ ```
147
+
148
+ * `role` takes a role name matching the `role:` the policy is built with — any symbol you like,
149
+ `:user`, `:provider_location`, `:contractor`, `:admin`.
150
+ * `action` takes a name plus a lambda or a block. The handler takes **no arguments**; passing one
151
+ that requires arguments raises `ArgumentError` at load time.
152
+ * `permitted_attributes` takes an optional label plus a list, a lambda or a block — see
153
+ [Permitted attributes](#permitted-attributes).
154
+ * `scope` takes an optional label plus a lambda or a block narrowing the relation the policy was
155
+ built with — see [Scopes](#scopes).
156
+ * `context` names the keywords the policy is built with, so handlers read them by name — see
157
+ [Context](#context).
158
+ * Handlers are `instance_exec`'d against the policy, so they can use `role`, every context keyword
159
+ and any helper method on the policy class.
160
+ * The result of an action is coerced with `!!`, so returning a record, `nil` or a string is fine.
161
+
162
+ ## Role resolution and the `:any` fallback
163
+
164
+ An action is looked up on the current role first, then on `:any`. Permitted attributes and scopes
165
+ resolve the same way, per label. This lets shared permissions be declared once instead of repeated
166
+ per role.
167
+
168
+ ```ruby
169
+ class BoatPolicy
170
+ include RolePlays::Mixin
171
+
172
+ context :current_role, :boat
173
+
174
+ role :any do
175
+ action :list, -> { true }
176
+ action :show, -> { true }
177
+ end
178
+
179
+ role :user do
180
+ action :create, -> { true }
181
+ action :update, -> { boat.user_id == current_role.id }
182
+ end
183
+
184
+ role :provider_location do
185
+ action :create, -> { true }
186
+ action :update, -> { boat.provider_location_id == current_role.id }
187
+ action :destroy, -> { true }
188
+ end
189
+
190
+ # Declares the role without granting anything beyond the :any actions
191
+ role :contractor
192
+ end
193
+ ```
194
+
195
+ With the example above:
196
+
197
+ | role | `:list` | `:create` | `:destroy` |
198
+ | ------------------- | ------- | --------- | ---------- |
199
+ | `user` | ✅ | ✅ | ❌ |
200
+ | `provider_location` | ✅ | ✅ | ✅ |
201
+ | `contractor` | ✅ | ❌ | ❌ |
202
+ | undeclared / `nil` | ✅ | ❌ | ❌ |
203
+
204
+ ## Declaring several roles at once
205
+
206
+ `role` takes more than one name — as a list or as an array — and files the same block under each of
207
+ them. The block is evaluated once, so roles that answer a question the same way declare it together
208
+ instead of repeating it. Declaring one of them again afterwards adds to what it already has, per
209
+ action and per label, so the shared part and the distinct part are read one after the other:
210
+
211
+ ```ruby
212
+ class WorkOrderPolicy
213
+ include RolePlays::Mixin
214
+
215
+ context :current_role, :work_order, :work_orders
216
+
217
+ # What working the location's jobs means, whichever of the two roles is asking
218
+ role %i[provider_location contractor] do
219
+ action :list, -> { true }
220
+ action :show, -> { same_location? }
221
+ action :log_time, -> { same_location? && work_order.in_progress? }
222
+
223
+ permitted_attributes :list, %i[page per_page state assignee_id]
224
+
225
+ scope :list, -> { work_orders.where(provider_location_id: current_role.provider_location_id) }
226
+ end
227
+
228
+ # ... and what only the location itself may do
229
+ role :provider_location do
230
+ action :create, -> { true }
231
+ action :destroy, -> { same_location? && work_order.draft? }
232
+
233
+ permitted_attributes %i[title description assignee_id scheduled_at]
234
+
235
+ scope :unassigned, -> { scope(:list).where(assignee_id: nil) }
236
+ end
237
+
238
+ # ... and what a contractor may do instead
239
+ role :contractor do
240
+ action :update, -> { assigned? && work_order.in_progress? }
241
+
242
+ permitted_attributes %i[state note]
243
+
244
+ scope :list, -> { work_orders.where(assignee_id: current_role.id) } # overrides the shared one
245
+ end
246
+
247
+ private
248
+
249
+ def same_location?
250
+ work_order.provider_location_id == current_role.provider_location_id
251
+ end
252
+
253
+ def assigned?
254
+ work_order.assignee_id == current_role.id
255
+ end
256
+ end
257
+ ```
258
+
259
+ | role | `:show` | `:log_time` | `:create` | `:update` | `permitted_attributes` | `scope(:list)` |
260
+ | ------------------- | ------- | ----------- | --------- | --------- | ---------------------- | ------------------ |
261
+ | `provider_location` | shared | shared | ✅ | ❌ | `title description …` | shared |
262
+ | `contractor` | shared | shared | ❌ | ✅ | `state note` | own assignments |
263
+
264
+ * The block is built once and filed under each name, so the two roles get the same declarations —
265
+ not a shared object they could change for each other.
266
+ * A later declaration of the same role merges into the earlier one and wins per action and per
267
+ label, so `:contractor` extends the shared block with `:update`, replaces its `:list` scope and
268
+ leaves everything else — including `provider_location`'s copy of that scope — untouched.
269
+ * The order reads as it is written: shared rules first, then what each role adds on top.
270
+ * Names and prebuilt `RolePlays::Mixin::Role` structs can be mixed in the same call — see
271
+ [Composition instead of inheritance](#composition-instead-of-inheritance).
272
+
273
+ ## Action predicates
274
+
275
+ Declaring an action also defines a `can_<action>?` predicate on the policy, so the mixin answers the
276
+ same messages a hand written policy does (`can_create?`, `can_update?`, `can_destroy?`, `can_list?`,
277
+ …) and can replace one without touching its callers.
278
+
279
+ ```ruby
280
+ class BoatPolicy
281
+ include RolePlays::Mixin
282
+
283
+ context :current_role, :boat
284
+
285
+ role :user do
286
+ action :create, -> { true }
287
+ action :destroy, -> { boat.user_id == current_role.id }
288
+ end
289
+
290
+ role :any do
291
+ action :list, -> { true }
292
+ end
293
+ end
294
+
295
+ policy = BoatPolicy.new(role: :user, current_role:, boat:)
296
+
297
+ policy.can_create? # => can?(:create)
298
+ policy.can_destroy? # => can?(:destroy)
299
+ policy.can_list? # => can?(:list) declared on :any
300
+ policy.can_import? # => NoMethodError no role declares :import
301
+ ```
302
+
303
+ * The predicates come from every `role` declaration, including shared roles, so an action declared
304
+ on any one role is callable on the policy — the handler is still resolved for the current role at
305
+ call time, and returns `false` when that role (and `:any`) does not declare it.
306
+ * Only declared actions get a predicate; anything else raises `NoMethodError` rather than quietly
307
+ answering `false`. Use `can?` for an action name computed at runtime.
308
+ * A predicate written by hand on the policy class wins over the generated one.
309
+ * `define_action_predicates` is public, so a policy resolving actions dynamically can declare the
310
+ extra predicates itself: `define_action_predicates(:accept, :decline)`.
311
+
312
+ ## Context
313
+
314
+ `role:` is the only argument `new` requires. Every other keyword is arbitrary — it is kept as
315
+ context and answered as a reader, so the policy is given what it actually talks about instead of a
316
+ fixed record/relation/options triple:
317
+
318
+ ```ruby
319
+ class OrderPolicy
320
+ include RolePlays::Mixin
321
+
322
+ context :user, :order, :order_relation
323
+
324
+ role :user do
325
+ action :destroy, -> { order.user_id == user.id }
326
+ action :edit, -> { order.user_id == user.id && order.completed? }
327
+
328
+ permitted_attributes(:create) { %i[title description] + (order ? [:user_id] : []) }
329
+
330
+ scope :list, -> { order_relation.where(user_id: user.id) }
331
+ end
332
+ end
333
+
334
+ OrderPolicy.new(role: :user, user: current_user, order: order, order_relation: Order.completed)
335
+ ```
336
+
337
+ * Any keyword `new` is given is readable by name — `context` does not have to be declared.
338
+ * What the declaration adds is the `nil`: a **declared** name reads as `nil` when the caller leaves
339
+ it out, so a handler can treat it as optional (`order ? … : …` above). An **undeclared** name
340
+ raises `NameError`, so a typo in a handler is not quietly read as `nil`.
341
+ * A reader written by hand on the policy wins over the generated one.
342
+ * A keyword named after a method the policy already answers — `can?`, `context`, a helper of its
343
+ own — raises `ArgumentError`, since its reader could never be reached.
344
+ * The keywords are also available as a hash through `context`, and the declared names through
345
+ `.policy_context_keys`.
346
+
347
+ Each policy names the things it talks about, so no two of them have to agree on a `record` /
348
+ `relation` / `options` shape they do not share:
349
+
350
+ ```ruby
351
+ class WorkOrderPolicy
352
+ context :current_role, :work_order, :work_orders
353
+ end
354
+
355
+ class InvoicePolicy
356
+ context :current_role, :invoice, :invoices, :period
357
+ end
358
+
359
+ class SchedulerEventPolicy
360
+ context :current_role, :event, :events, :calendar, :requested_at
361
+ end
362
+
363
+ class OrderPolicy
364
+ context :current_role, :order, :orders, :child_account, :token_scopes
365
+ end
366
+ ```
367
+
368
+ A handler then reads what it is about, and a caller passes only the keywords the question needs:
369
+
370
+ ```ruby
371
+ InvoicePolicy.new(role:, current_role:, invoice:).can?(:send)
372
+ InvoicePolicy.new(role:, current_role:, invoices: Invoice.kept, period: 1.month.ago..).scope(:report)
373
+
374
+ OrderPolicy.new(role:, current_role:, order:, child_account:).can?(:update)
375
+ OrderPolicy.new(role:, current_role:, token_scopes: current_user.claims[:scopes]).can?(:create)
376
+ OrderPolicy.new(role:).permitted_attributes(:list)
377
+ ```
378
+
379
+ The keywords left out read as `nil`, so one policy answers a question about a record, about a
380
+ relation and about a bare role without three constructors — and a handler that needs more context
381
+ asks for it by name instead of being handed an `options` hash to dig through.
382
+
383
+ ## Permitted attributes
384
+
385
+ `permitted_attributes` declares the parameter list a role may submit, keyed by a label. The label is
386
+ optional and defaults to `:default`, which is what `policy.permitted_attributes` returns when called
387
+ without arguments.
388
+
389
+ ```ruby
390
+ class BoatPolicy
391
+ include RolePlays::Mixin
392
+
393
+ context :boat
394
+
395
+ role :any do
396
+ permitted_attributes :list, %i[page per_page search sort_name_asc]
397
+ end
398
+
399
+ role :user do
400
+ permitted_attributes %i[name model_id year] # :default
401
+ permitted_attributes :create, %i[name model_id year user_id]
402
+ permitted_attributes(:update) do # computed per instance
403
+ own_boat? ? %i[name model_id year] : []
404
+ end
405
+ end
406
+ end
407
+ ```
408
+
409
+ * The attributes can be given literally (an array, a hash, or a single symbol) or computed by a
410
+ lambda or a block. Callables are `instance_exec`'d against the policy exactly like action
411
+ handlers, so `role`, the context keywords and helper methods are available.
412
+ * The result is always wrapped in an array, so it can be handed straight to `permit`.
413
+ * A label declared on no role — or a role with no attributes at all — yields `[]`.
414
+ * Declaring the same label twice overrides it; other labels are untouched.
415
+
416
+ ```ruby
417
+ policy = BoatPolicy.new(role: :user, boat:)
418
+
419
+ policy.permitted_attributes # => %i[name model_id year]
420
+ policy.permitted_attributes(:create) # => %i[name model_id year user_id]
421
+ policy.permitted_attributes(:list) # => %i[page per_page search sort_name_asc] (from :any)
422
+ policy.permitted_attributes(:import) # => []
423
+
424
+ params.permit(policy.permitted_attributes(:create))
425
+ ```
426
+
427
+ A role declares as many lists as it has actions to declare them for, so what may be *sent* is
428
+ declared next to what may be *done*, and one policy answers every one of them:
429
+
430
+ ```ruby
431
+ role :provider_location do
432
+ action :create, -> { true }
433
+ action :update, -> { own_order? }
434
+ action :invoice, -> { own_order? && order.completed? }
435
+
436
+ permitted_attributes :create, %i[user_id boat_id service_id comments]
437
+ permitted_attributes :update, %i[boat_id service_id comments status scheduled_at]
438
+ permitted_attributes :invoice, %i[deposit discount tax_rate_group_id]
439
+ permitted_attributes :list, %i[page per_page state search]
440
+ end
441
+ ```
442
+
443
+ ```ruby
444
+ policy = OrderPolicy.new(role:, current_role:, order:)
445
+
446
+ policy.can?(:invoice) # what may be done
447
+ params.permit(policy.permitted_attributes(:invoice)) # what may be sent doing it
448
+ ```
449
+
450
+ A policy exposing one anonymous list per role has no answer for a role whose create form differs
451
+ from its update form: it either takes the union of the two — the wider list quietly applying to both
452
+ — or grows a second method (`update_permitted_attributes`) that only the callers knowing about it
453
+ will use. A label is the missing name.
454
+
455
+ Nested attributes are declared the way `permit` expects them:
456
+
457
+ ```ruby
458
+ role :provider_location do
459
+ permitted_attributes :update, [
460
+ :description, :labor_rate,
461
+ { technician_attributes: %i[id _destroy ids_mechanic_id], types_of_service: [] }
462
+ ]
463
+ end
464
+ ```
465
+
466
+ ## Scopes
467
+
468
+ `scope` declares how a role narrows a relation, keyed by a label exactly like
469
+ `permitted_attributes`. The relation is one of the policy's own keywords, named by the policy
470
+ rather than by the DSL.
471
+
472
+ ```ruby
473
+ class BoatPolicy
474
+ include RolePlays::Mixin
475
+
476
+ context :current_role, :boats, :period
477
+
478
+ role :any do
479
+ scope :list, -> { boats.where(archived: false) }
480
+ end
481
+
482
+ role :user do
483
+ scope -> { boats.where(user_id: current_role.id) } # :default
484
+ scope :list, -> { boats.where(user_id: current_role.id, archived: false) }
485
+ scope(:report) do # computed per instance
486
+ boats.where(user_id: current_role.id, created_at: period)
487
+ end
488
+ end
489
+
490
+ role :provider_location do
491
+ scope -> { boats.where(provider_location_id: current_role.id) }
492
+ end
493
+ end
494
+ ```
495
+
496
+ ```ruby
497
+ policy = BoatPolicy.new(role: :user, current_role:, boats: Boat.all)
498
+
499
+ policy.scope # => Boat.where(user_id: 1) the :default label
500
+ policy.scope(:list) # => Boat.where(user_id: 1, archived: false)
501
+ policy.scope(:unknown) # => nil undeclared
502
+ ```
503
+
504
+ * A scope must be a lambda or a block — a literal relation would be evaluated at load time.
505
+ It takes **no arguments**, and is `instance_exec`'d against the policy like every other handler.
506
+ * An undeclared label — or a role with no scopes at all — yields `nil`. The policy names its own
507
+ relation, so what "nothing is visible" means is left to the caller:
508
+
509
+ ```ruby
510
+ def boats
511
+ BoatPolicy.new(role:, current_role:, boats: Boat.all).scope(:list) || Boat.none
512
+ end
513
+ ```
514
+
515
+ A policy instance answers one scope per label, so build a new instance per relation.
516
+
517
+ ## Helper methods
518
+
519
+ Non trivial conditions read better as predicate methods on the policy. They are ordinary instance
520
+ methods, so they are available to every handler.
521
+
522
+ ```ruby
523
+ class WorkOrderPolicy
524
+ include RolePlays::Mixin
525
+
526
+ context :current_role, :work_order
527
+
528
+ role :provider_location do
529
+ action :create, -> { tier_available? }
530
+ action :update, -> { own_work_order? && editable_state? }
531
+ action :destroy, -> { own_work_order? && work_order.draft? }
532
+ end
533
+
534
+ role :contractor do
535
+ action :update, -> { assigned? && editable_state? }
536
+ end
537
+
538
+ private
539
+
540
+ def own_work_order?
541
+ work_order.provider_location_id == current_role.id
542
+ end
543
+
544
+ def assigned?
545
+ work_order.assignee_id == current_role.id
546
+ end
547
+
548
+ def editable_state?
549
+ work_order.state.in?(%w[draft unassigned assigned dispatched])
550
+ end
551
+
552
+ def tier_available?
553
+ !current_role.provider.subscription_tier_inactive?
554
+ end
555
+ end
556
+ ```
557
+
558
+ The policy only knows the role *name*, so a handler needing the role record itself is given it as a
559
+ keyword — `current_role:` by convention.
560
+
561
+ ## Composition instead of inheritance
562
+
563
+ Nothing is inherited: a policy answers for the roles it declares itself, and subclassing carries
564
+ none of them. A role shared between policies is built once as a `RolePlays::Mixin::Role` and
565
+ declared in each policy that wants it:
566
+
567
+ ```ruby
568
+ module SharedRoles
569
+ READ_ONLY_ADMIN = RolePlays::Mixin::RoleBuilder.build(:admin) do
570
+ action :list, -> { true }
571
+ action :show, -> { true }
572
+
573
+ permitted_attributes :list, %i[page per_page]
574
+
575
+ scope :list, -> { invoices.all }
576
+ end
577
+ end
578
+
579
+ class InvoicePolicy
580
+ include RolePlays::Mixin
581
+
582
+ context :current_role, :invoice, :invoices
583
+
584
+ role SharedRoles::READ_ONLY_ADMIN
585
+
586
+ role :provider_location do
587
+ action :list, -> { true }
588
+ action :send, -> { invoice.provider_location_id == current_role.id }
589
+ end
590
+ end
591
+ ```
592
+
593
+ Declaring the same role twice merges the actions, the attribute labels and the scope labels, and
594
+ the later declaration wins, so a shared role is extended locally without affecting the policies it
595
+ is shared with:
596
+
597
+ ```ruby
598
+ class CreditNotePolicy
599
+ include RolePlays::Mixin
600
+
601
+ context :credit_notes
602
+
603
+ role SharedRoles::READ_ONLY_ADMIN
604
+
605
+ role :admin do
606
+ action :void, -> { true } # adds to the shared :list / :show
607
+ action :show, -> { false } # overrides the shared handler
608
+
609
+ permitted_attributes %i[reason amount] # adds the :default label
610
+ permitted_attributes :list, %i[page] # overrides the shared :list label
611
+
612
+ scope :list, -> { credit_notes.where(voided: false) } # overrides the shared :list scope
613
+ end
614
+ end
615
+ ```
616
+
617
+ A policy that shares most of another one's rules composes the same `Role` structs; it does not
618
+ subclass it.
619
+
620
+ ## Supplying the role
621
+
622
+ Nothing in the gem reads a user. `role:` is a symbol the caller passes, so where it comes from is
623
+ yours to decide — a token claim, a column, a form, a constant:
624
+
625
+ ```ruby
626
+ OrderPolicy.new(role: current_user.role.to_sym, order:) # straight off the user
627
+ OrderPolicy.new(role: OrderFormRoleSelector.new(form_token).role, order:) # a public form
628
+ OrderPolicy.new(role: :provider_location, current_role: location, order:) # a background job
629
+ OrderPolicy.new(role: :admin, order:) # a rake task
630
+ ```
631
+
632
+ A selector of your own is the usual place to put that decision, and it is the place to refine a base
633
+ role into the **virtual roles** the rules answer for separately — roles no token carries:
634
+
635
+ ```ruby
636
+ class RoleSelector
637
+ def initialize(user)
638
+ @user = user
639
+ @role = user.role.to_sym
640
+ end
641
+
642
+ attr_reader :role
643
+
644
+ # A :user acting through an access code — a token carrying scopes — is its own role
645
+ def access_code
646
+ @role = :access_code if role == :user && @user.claims[:scopes].present?
647
+ self
648
+ end
649
+
650
+ # A :contractor whose directory does technician work is its own role
651
+ def technician
652
+ @role = :technician if role == :contractor && @user.current_role.technician?
653
+ self
654
+ end
655
+ end
656
+
657
+ RoleSelector.new(current_user).role # => :provider_location
658
+ RoleSelector.new(current_user).access_code.role # => :access_code, else :user
659
+ RoleSelector.new(current_user).access_code.technician.role # refinements chain
660
+ ```
661
+
662
+ * A refinement only applies to the role it is about, so asking for one leaves every other role alone
663
+ and the order they are asked in does not matter.
664
+ * A policy asks for the distinctions it declares. One that treats access code users like any other
665
+ user simply does not call `access_code` and never sees the role.
666
+
667
+ That is what makes a virtual role cheap: it is a role like any other, declared once, instead of a
668
+ condition repeated in every handler that has to care.
669
+
670
+ ```ruby
671
+ class OrderPolicy
672
+ include RolePlays::Mixin
673
+
674
+ context :current_role, :order, :token_scopes
675
+
676
+ role :user do
677
+ action :create, -> { true }
678
+ action :destroy, -> { own_order? }
679
+
680
+ permitted_attributes :create, %i[boat_id service_id comments]
681
+ end
682
+
683
+ role :access_code do
684
+ action :create, -> { token_scopes.include?('create_order') }
685
+ action :destroy, -> { false }
686
+
687
+ permitted_attributes :create, %i[boat_id service_id]
688
+ end
689
+
690
+ private
691
+
692
+ def own_order?
693
+ order.user_id == current_role.id
694
+ end
695
+ end
696
+
697
+ OrderPolicy.new(role: RoleSelector.new(current_user).access_code.role,
698
+ current_role: current_user.current_role,
699
+ order:,
700
+ token_scopes: current_user.claims[:scopes])
701
+ ```
702
+
703
+ An unauthenticated request has no role to select from, and a `nil` role is fine to build a policy
704
+ with — it leaves only the `:any` declarations, so nothing needs special casing:
705
+
706
+ ```ruby
707
+ role = current_user && RoleSelector.new(current_user).role
708
+ ```
709
+
710
+ ## Building a policy
711
+
712
+ No factory lookup is needed, because the role picks the declarations inside the policy. `new` is
713
+ the only entry point, and `role:` its only required argument:
714
+
715
+ ```ruby
716
+ OrderPolicy.new(role: :user, user: current_user, order: order).can?(:destroy)
717
+ OrderPolicy.new(role: :user, order: order).permitted_attributes(:create)
718
+ OrderPolicy.new(role: :user, order_relation: Order.all).scope(:list)
719
+ ReportPolicy.new(role: :user).can?(:list)
720
+ ```
721
+
722
+ Pass only the keywords the question needs — the ones left out read as `nil` when they are declared
723
+ with `context`.
724
+
725
+ `role:` is the role name as a symbol; strings are accepted and converted, so a value read straight
726
+ off a record or a token can be passed through. A `nil` role leaves only the `:any` declarations. A
727
+ handler that needs the role record itself is given it as a keyword. See
728
+ [Supplying the role](#supplying-the-role).
729
+
730
+ ## Calling a policy
731
+
732
+ ```ruby
733
+ module Mutations
734
+ module Orders
735
+ class DestroyOrder < BaseMutation
736
+ def resolve(id:)
737
+ raise GraphQL::ExecutionError, 'Forbidden' if policy.cannot?(:destroy)
738
+
739
+ # ...
740
+ end
741
+
742
+ private
743
+
744
+ def order
745
+ @order ||= Order.find(id)
746
+ end
747
+
748
+ def policy
749
+ @policy ||= OrderPolicy.new(role: current_user && RoleSelector.new(current_user).access_code.role,
750
+ current_role: current_user&.current_role,
751
+ order:)
752
+ end
753
+ end
754
+ end
755
+ end
756
+ ```
757
+
758
+ ## Testing
759
+
760
+ Name the role under test and assert on `can?` and `permitted_attributes` — no user or token double
761
+ is needed:
762
+
763
+ ```ruby
764
+ RSpec.describe OrderPolicy do
765
+ subject(:policy) { described_class.new(role:, current_role:, order:) }
766
+
767
+ context 'with a user role' do
768
+ let(:role) { :user }
769
+ let(:current_role) { create(:user_role) }
770
+ let(:order) { create(:order, user: current_role) }
771
+
772
+ it { expect(policy.can?(:destroy)).to be(true) }
773
+ it { expect(described_class.new(role:, current_role:, order: create(:order)).can?(:destroy)).to be(false) }
774
+ it { expect(policy.permitted_attributes(:create)).to eq(%i[title description]) }
775
+ end
776
+
777
+ context 'without a role' do
778
+ let(:role) { nil }
779
+ let(:current_role) { nil }
780
+ let(:order) { create(:order) }
781
+
782
+ it 'still applies the :any role' do
783
+ expect(policy.can?(:list)).to be(true)
784
+ end
785
+ end
786
+ end
787
+ ```
788
+
789
+ Note that a role is filed and compared as a symbol. `new` calls `to_sym` on whatever it is given, so
790
+ a string — or anything answering `to_sym`, such as an `ActiveSupport::StringInquirer` — can be passed
791
+ straight through; a handler comparing the role itself should compare symbols.
792
+
793
+ See [spec/role_plays/mixin_spec.rb](spec/role_plays/mixin_spec.rb) for the full behaviour of the DSL.
794
+
795
+ ## Why one policy instead of a class per role
796
+
797
+ The usual alternative splits one question across a file per role — `OrderPolicies::User`,
798
+ `OrderPolicies::Admin`, a `Scope` class inside each, a module for the list two of them share and a
799
+ factory mapping roles to classes. Five files before anything is answered:
800
+
801
+ ```ruby
802
+ # app/policies/order_policies/user.rb
803
+ module OrderPolicies
804
+ class User < BasePolicy
805
+ def can_create?
806
+ true
807
+ end
808
+
809
+ def can_destroy?
810
+ record.user_id == user.id
811
+ end
812
+
813
+ def permitted_attributes
814
+ CommonAttributes.common_attrs
815
+ end
816
+
817
+ class Scope < BasePolicy::BaseScope
818
+ def manage
819
+ relation.where(user_id: user.id)
820
+ end
821
+ end
822
+ end
823
+ end
824
+ ```
825
+
826
+ What a policy gains by being one class:
827
+
828
+ * **The definition is in one place for all roles.** Answering "who may destroy an order?" is one
829
+ screen instead of three files opened side by side, and the roles are read against each other
830
+ rather than one at a time. Adding a role adds a block, not a file, a class and a factory entry.
831
+ * **Common logic is easy to share.** `role %i[user provider_location]` declares a rule once, `:any`
832
+ declares one that holds for everyone, and a `RolePlays::Mixin::Role` struct shares one between
833
+ *policies* — where a class per role can only share through inheritance from a base, which is why
834
+ identical `can_create?` bodies get copied across sibling files.
835
+ * **Handlers read descriptive names, not `record`.** `context` lets a policy name the things it talks
836
+ about — `order`, `orders`, `period`, `token_scopes` — instead of handing every policy the same
837
+ `user` / `record` / `options` triple to dig through.
838
+ * **It is not tied to `current_user`.** The policy is told the role name, so the same rules answer
839
+ for a request, a background job, a rake task, a public form with a selector of its own, or a spec
840
+ that just writes `role: :contractor` — no user, no token, no stubbing.
841
+ * **Attributes and scopes gain names.** Labels replace one anonymous `permitted_attributes` per role
842
+ and a `Scope` class whose variants are method names the call site has to know.
843
+ * **Virtual roles become ordinary roles.** A distinction like "a user acting through an access code"
844
+ is declared once next to the role it differs from, instead of a condition written into every
845
+ method that remembered to check it.
846
+ * **It encourages one liners.** `action :create, -> { true }` against a three line `def can_create?`
847
+ … `end`, so a role's rules are a list to be scanned; anything longer becomes a named predicate
848
+ (`own_order?`), which is where the reading actually happens.
849
+
850
+ ## Development
851
+
852
+ ```sh
853
+ bin/setup # install dependencies
854
+ bundle exec rake # specs and RuboCop
855
+ bin/console # an IRB session with the gem loaded
856
+ ```
857
+
858
+ `rake install` installs the gem locally. To release a version, update `RolePlays::VERSION` and the
859
+ [CHANGELOG](CHANGELOG.md), then run `bundle exec rake release`, which tags the version, pushes the
860
+ commit and the tag, and pushes the `.gem` to [rubygems.org](https://rubygems.org).
861
+
862
+ ## Contributing
863
+
864
+ Bug reports and pull requests are welcome. Please add specs alongside a change — `bundle exec rake`
865
+ runs the suite and RuboCop, and both should be green.
866
+
867
+ ## License
868
+
869
+ Available as open source under the terms of the [MIT License](LICENSE.txt).