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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/LICENSE.txt +21 -0
- data/README.md +869 -0
- data/Rakefile +12 -0
- data/lib/role_plays/base_struct.rb +14 -0
- data/lib/role_plays/mixin.rb +392 -0
- data/lib/role_plays/types.rb +10 -0
- data/lib/role_plays/version.rb +5 -0
- data/lib/role_plays.rb +10 -0
- data/sig/role_plays.rbs +4 -0
- metadata +74 -0
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).
|