sequel-privacy 0.2.1 → 0.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04b5728dab12a15490535725e9458d1039e17049f47e839e724b5464b1e39ee5
4
- data.tar.gz: ddb26ed4c76c15b672043d2ac32ff4573b791befc8c9f3b1d0405e0c4ddcd6ba
3
+ metadata.gz: 651aa5392073590a594f91df25458c4e2594ae6de3d8157b53525bfe210ebeef
4
+ data.tar.gz: 6addd80f151b6a1b272e5f70376ac41e18fd84e7a60cc77d68017d3c6b646dc6
5
5
  SHA512:
6
- metadata.gz: 1b235a397d24fbfea16f557628554b5e64352a2872b54d9b51b36e81d13fe659be3b1a011e256626db7025dc7527b33e891866c116e4a1dbe4a03821209401c6
7
- data.tar.gz: 105bb81dc55bb0970a224682c7ab30117758e882db8acac968802da1ed0fa06bd29145350d9de196dda2f6b5d0f81b7c58433e1107317ab8f4b7d297a5ee2c53
6
+ metadata.gz: cb083193065e4d0929beb64dc3f51029e375861d42f5800da2218a3699b1adcd37c16aea5dc55f3cce0d250322967bed98f3c2e53119a693d8e7e9f0111bdab6
7
+ data.tar.gz: 83840dfe5cded9036995df6371c083c9ab72e983f0c3de1eee69ff7a6b56b0b4e00c3ede0c6225e71669ce3bb14d981f08095c49153c5e75bacf6ee6896c072f
data/README.md CHANGED
@@ -31,23 +31,26 @@ module P
31
31
  AlwaysAllow = Sequel::Privacy::BuiltInPolicies::AlwaysAllow
32
32
  PassAndLog = Sequel::Privacy::BuiltInPolicies::PassAndLog
33
33
 
34
- policy :AllowIfPublished, ->(subject) {
34
+ # State-gate policies examine only the subject. Declare
35
+ # `allow_anonymous: true` so logged-out viewers can still pass.
36
+ policy :AllowIfPublished, ->(_actor, subject) {
35
37
  allow if subject.published
36
- }
38
+ }, allow_anonymous: true, cache_by: :subject
37
39
 
38
- policy :AllowAdmins, ->(_subject, actor) {
40
+ # Actor-only role checks — cheap because they cache per-actor.
41
+ policy :AllowAdmins, ->(actor) {
39
42
  allow if actor.is_role?(:admin)
40
43
  }, 'Allow admin users', cacheable: true
41
-
42
- policy :AllowMembers, ->(_subject, actor) {
44
+
45
+ policy :AllowMembers, ->(actor) {
43
46
  allow if actor.is_role?(:member)
44
47
  }, cacheable: true
45
48
 
46
- policy :AllowSelf, ->(subject, actor) {
49
+ policy :AllowSelf, ->(actor, subject) {
47
50
  allow if subject == actor
48
- }, 'Allow if subject is the actor', single_match: true
49
-
50
- policy :AllowFriendsOfSubject, ->(subject, actor) {
51
+ }, 'Allow if subject is the actor', single_match: true
52
+
53
+ policy :AllowFriendsOfSubject, ->(actor, subject) {
51
54
  allow if subject.includes_friend?(actor)
52
55
  }
53
56
  end
@@ -107,28 +110,34 @@ member.phone # => nil if :view_phone denies
107
110
 
108
111
  ## Policy Definition
109
112
 
110
- Policies are lambdas that execute in the context of an `Actions` struct, giving access to `allow`, `deny`, and `pass` outcome methods, as well as the `all` combinator. `allow` and `deny` will end evaluation of the chain of policies, whereas `pass` will continue to the next policy in the chain.
113
+ Policies are lambdas that execute in the context of an `Actions` struct, giving access to `allow`, `deny`, and `pass` outcome methods, as well as the `all` combinator. `allow` and `deny` will end evaluation of the chain of policies, whereas `pass` will continue to the next policy in the chain.
114
+
115
+ Policies are **actor-first**. Arities map to:
116
+ - 0 args — global decision (`-> { allow if Time.now.sunday? }`)
117
+ - 1 arg — `(actor)`: role / identity checks
118
+ - 2 args — `(actor, subject)`: ownership, membership
119
+ - 3 args — `(actor, subject, direct_object)`: "can actor do X to subject with direct_object?"
111
120
 
112
- Policies accept up to three parameters: `actor`, `subject` & `actor` or `subject`, `actor` and `direct_object`.
121
+ Policies of arity 1 auto-deny for anonymous viewers (nil actor). Use `allow_anonymous: true` to opt out — meant for state-gate policies that examine only the subject.
113
122
 
114
123
 
115
124
  ```ruby
116
125
 
117
126
  policy :AlwaysAllow, -> { allow }
118
127
 
119
- policy :AllowIfPublished, ->(subject) {
128
+ policy :AllowIfPublished, ->(_actor, subject) {
120
129
  allow if subject.published
121
- }
130
+ }, allow_anonymous: true, cache_by: :subject
122
131
 
123
- policy :AllowAdmins, ->(_subject, actor) {
132
+ policy :AllowAdmins, ->(actor) {
124
133
  allow if actor.is_role?(:admin)
125
134
  }
126
135
 
127
- policy :AllowOwner, ->(_subject, actor) {
136
+ policy :AllowOwner, ->(actor, subject) {
128
137
  allow if subject.owner_id == actor.id
129
138
  }
130
139
 
131
- policy :AllowIfDirectObjectIsActor, ->(_subject, actor, direct_object) {
140
+ policy :AllowIfDirectObjectIsActor, ->(actor, _subject, direct_object) {
132
141
  allow if actor.id == direct_object.id
133
142
  }
134
143
 
@@ -141,14 +150,14 @@ different modules.
141
150
  module P
142
151
  module Groups
143
152
  extend Sequel::Privacy::PolicyDSL
144
-
145
- policy :AllowIfOpen, -> (subject, _actor) {
153
+
154
+ policy :AllowIfOpen, ->(_actor, subject) {
146
155
  allow if subject.open?
147
- }
148
-
149
- policy :AllowIfMember, -> (subject, actor) {
156
+ }, allow_anonymous: true, cache_by: :subject
157
+
158
+ policy :AllowIfMember, ->(actor, subject) {
150
159
  allow if subject.includes_member? actor
151
- }
160
+ }
152
161
  end
153
162
  end
154
163
  ```
@@ -159,25 +168,31 @@ end
159
168
  policy :MyPolicy, ->() { ... },
160
169
  'Human-readable description', # For logging
161
170
  cacheable: true, # Cache results (default: true)
162
- single_match: false # Only one subject can match
171
+ single_match: false, # Only one subject can match
172
+ cache_by: :actor, # Override cache-key dimensions
173
+ allow_anonymous: false # Allow nil actor (opts out of auto-deny)
163
174
  ```
164
175
 
165
176
  **`cacheable: true`** (default): Results are cached for the duration of the request, keyed by policy + arguments. Use for policies that don't depend on mutable state.
166
177
 
167
- **`single_match: true`**: Optimization for policies for which there is only one matching Actor possible for a given Subject. For example in `AllowAuthors`, since a `Post` can have only one other, it's not worth a potentially expensive check on other combinations once you've found the winner.
178
+ **`single_match: true`**: Optimization for policies for which there is only one matching Actor possible for a given Subject. For example in `AllowAuthors`, since a `Post` can have only one other, it's not worth a potentially expensive check on other combinations once you've found the winner.
179
+
180
+ **`cache_by:`** (Symbol or Array of `:actor`, `:subject`, `:direct_object`): Override the cache-key dimensions. By default the key uses every input the policy receives. Pass a subset when the policy ignores some of its inputs — e.g. `AllowAdmins` takes `(actor, subject)` but only examines actor, so `cache_by: :actor` shares one entry across subjects.
181
+
182
+ **`allow_anonymous: true`**: Skip the auto-deny for nil actor. Use for state-gate policies that examine only the subject (e.g. "post is published").
168
183
 
169
184
  ### Policy Combinators
170
185
 
171
186
  Use `all()` to require multiple conditions:
172
187
 
173
188
  ```ruby
174
- policy :AllowAddSelfToOpenGroup, ->(subject, actor, direct_object) {
189
+ policy :AllowAddSelfToOpenGroup, ->(actor, subject, direct_object) {
175
190
  all(
176
- P::AllowIfGroupIsOpen
191
+ P::AllowIfGroupIsOpen,
177
192
  P::AllowIfDirectObjectIsActor
178
193
  )
179
194
  }
180
- policy :AllowRemoveSelf, ->(subject, actor, direct_object) {
195
+ policy :AllowRemoveSelf, ->(actor, subject, direct_object) {
181
196
  all(
182
197
  P::AllowIfIncludesMember,
183
198
  P::AllowIfDirectObjectIsActor
@@ -308,25 +323,25 @@ The `association` block supports three actions:
308
323
  - `:remove` - Wraps `remove_*` method (e.g., `remove_member`)
309
324
  - `:remove_all` - Wraps `remove_all_*` method (e.g., `remove_all_members`)
310
325
 
311
- Association policies receive `(subject, actor, direct_object)`:
312
- - `subject` - The model instance (e.g., the group)
326
+ Association policies receive `(actor, subject, direct_object)`:
313
327
  - `actor` - The current user from the viewer context
328
+ - `subject` - The model instance (e.g., the group)
314
329
  - `direct_object` - The object being added/removed (e.g., the user being added to the group)
315
330
 
316
331
  For `remove_all`, the direct object is `nil` since there's no specific target.
317
332
 
318
333
  ```ruby
319
334
  # Allow users to add/remove themselves
320
- policy :AllowSelfJoin, ->(_subject, actor, direct_object) {
335
+ policy :AllowSelfJoin, ->(actor, _subject, direct_object) {
321
336
  allow if actor.id == direct_object.id
322
337
  }, single_match: true
323
338
 
324
- policy :AllowSelfRemove, ->(_subject, actor, direct_object) {
339
+ policy :AllowSelfRemove, ->(actor, _subject, direct_object) {
325
340
  allow if actor.id == direct_object.id
326
341
  }, single_match: true
327
342
 
328
343
  # Allow group admins to add/remove anyone
329
- policy :AllowGroupAdmin, ->(subject, actor, direct_object) {
344
+ policy :AllowGroupAdmin, ->(actor, subject, _direct_object) {
330
345
  allow if subject.includes_admin?(actor)
331
346
  }
332
347
  ```
@@ -302,6 +302,8 @@ module Sequel
302
302
  vc = instance_variable_get(:@viewer_context)
303
303
 
304
304
  unless vc
305
+ return original_method.bind(self).() if T.unsafe(self.class).allow_unsafe_access?
306
+
305
307
  Kernel.raise Sequel::Privacy::MissingViewerContext,
306
308
  "#{self.class}##{field} requires a ViewerContext"
307
309
  end
@@ -518,6 +520,8 @@ module Sequel
518
520
  vc = instance_variable_get(:@viewer_context)
519
521
 
520
522
  unless vc
523
+ return original.bind(self).(obj) if T.unsafe(self.class).allow_unsafe_access?
524
+
521
525
  Kernel.raise Sequel::Privacy::MissingViewerContext,
522
526
  "Cannot #{method_name} without a viewer context"
523
527
  end
@@ -548,6 +552,8 @@ module Sequel
548
552
  vc = instance_variable_get(:@viewer_context)
549
553
 
550
554
  unless vc
555
+ return original.bind(self).(obj) if T.unsafe(self.class).allow_unsafe_access?
556
+
551
557
  Kernel.raise Sequel::Privacy::MissingViewerContext,
552
558
  "Cannot #{method_name} without a viewer context"
553
559
  end
@@ -578,6 +584,8 @@ module Sequel
578
584
  vc = instance_variable_get(:@viewer_context)
579
585
 
580
586
  unless vc
587
+ return original.bind(self).() if T.unsafe(self.class).allow_unsafe_access?
588
+
581
589
  Kernel.raise Sequel::Privacy::MissingViewerContext,
582
590
  "Cannot #{method_name} without a viewer context"
583
591
  end
@@ -25,7 +25,7 @@ module Sequel
25
25
  # Pass and log - useful for debugging policy chains.
26
26
  PassAndLog = Policy.create(
27
27
  :PassAndLog,
28
- ->(subject, actor) {
28
+ ->(actor, subject) {
29
29
  Sequel::Privacy::Enforcer.logger&.info("PassAndLog: #{subject.class} for actor #{actor.id}")
30
30
  :pass
31
31
  },
@@ -97,15 +97,27 @@ module Sequel
97
97
  ).returns(Integer)
98
98
  end
99
99
  def self.compute_cache_key(policy, subject, actor, viewer_context, direct_object)
100
+ if (keys = policy.cache_by)
101
+ parts = T.let([policy, viewer_context], T::Array[T.untyped])
102
+ keys.each do |k|
103
+ parts << case k
104
+ when :actor then actor
105
+ when :subject then subject
106
+ when :direct_object then direct_object
107
+ end
108
+ end
109
+ return parts.hash
110
+ end
111
+
100
112
  case policy.arity
101
113
  when 0
102
114
  [policy, viewer_context].hash
103
115
  when 1
104
- [policy, subject, viewer_context].hash
116
+ [policy, actor, viewer_context].hash
105
117
  when 2
106
- [policy, subject, actor, viewer_context].hash
118
+ [policy, actor, subject, viewer_context].hash
107
119
  else
108
- [policy, subject, actor, direct_object, viewer_context].hash
120
+ [policy, actor, subject, direct_object, viewer_context].hash
109
121
  end
110
122
  end
111
123
 
@@ -211,8 +223,11 @@ module Sequel
211
223
  ).returns(T.untyped)
212
224
  end
213
225
  def self.execute_policy(policy, subject, actor, direct_object)
214
- # 2+ arity policies require actor - auto-deny for anonymous
215
- if !actor && policy.arity >= 2
226
+ # Policies with arity >= 1 expect an actor as the first arg.
227
+ # Anonymous viewers (no actor) auto-deny unless the policy opts in
228
+ # with allow_anonymous: true (for state-gate policies that examine
229
+ # only the subject).
230
+ if !actor && policy.arity >= 1 && !policy.allow_anonymous?
216
231
  return :deny
217
232
  end
218
233
 
@@ -220,11 +235,11 @@ module Sequel
220
235
  when 0
221
236
  Actions.evaluate(&policy)
222
237
  when 1
223
- Actions.evaluate(subject, &policy)
238
+ Actions.evaluate(actor, &policy)
224
239
  when 2
225
- Actions.evaluate(subject, T.must(actor), &policy)
240
+ Actions.evaluate(actor, subject, &policy)
226
241
  else
227
- Actions.evaluate(subject, T.must(actor), direct_object, &policy)
242
+ Actions.evaluate(actor, subject, direct_object, &policy)
228
243
  end
229
244
  end
230
245
 
@@ -5,11 +5,15 @@ module Sequel
5
5
  module Privacy
6
6
  # A Policy wraps a Proc/lambda with metadata about how it should be evaluated.
7
7
  #
8
- # Policies take 0-3 arguments depending on what context they need:
9
- # - 0 args: -> { allow } # Global decision
8
+ # Policies are actor-first. Arities map to:
9
+ # - 0 args: -> { allow if Time.now.sunday? } # Global decision
10
10
  # - 1 arg: ->(actor) { allow if actor.is_role?(:admin) }
11
- # - 2 args: ->(subject, actor) { allow if subject.owner_id == actor.id }
12
- # - 3 args: ->(subject, actor, direct_object) { ... }
11
+ # - 2 args: ->(actor, subject) { allow if subject.owner_id == actor.id }
12
+ # - 3 args: ->(actor, subject, direct_object) { ... }
13
+ #
14
+ # Any policy with arity >= 1 auto-denies for anonymous viewers (nil actor)
15
+ # unless declared with `allow_anonymous: true`. That flag is for state-gate
16
+ # policies that deliberately ignore actor — e.g. "post is published."
13
17
  #
14
18
  # Policies must return :allow, :deny, :pass, or an array of policies (for combinators).
15
19
  class Policy < Proc
@@ -21,6 +25,8 @@ module Sequel
21
25
  sig { returns(T.nilable(String)) }
22
26
  attr_reader :comment
23
27
 
28
+ VALID_CACHE_BY = T.let(%i[actor subject direct_object].freeze, T::Array[Symbol])
29
+
24
30
  # Factory method for creating policies. Accepts procs of any arity
25
31
  # (0–3 args) returning :allow, :deny, :pass, or an Array of policies.
26
32
  sig do
@@ -29,15 +35,20 @@ module Sequel
29
35
  lam: Proc,
30
36
  comment: T.nilable(String),
31
37
  cacheable: T::Boolean,
32
- single_match: T::Boolean
38
+ single_match: T::Boolean,
39
+ cache_by: T.nilable(T.any(Symbol, T::Array[Symbol])),
40
+ allow_anonymous: T::Boolean
33
41
  ).returns(T.self_type)
34
42
  end
35
- def self.create(policy_name, lam, comment = nil, cacheable: true, single_match: false)
43
+ def self.create(policy_name, lam, comment = nil, cacheable: true, single_match: false, cache_by: nil,
44
+ allow_anonymous: false)
36
45
  new(&lam).setup(
37
46
  policy_name: policy_name,
38
47
  comment: comment,
39
48
  cacheable: cacheable,
40
- single_match: single_match
49
+ single_match: single_match,
50
+ cache_by: cache_by,
51
+ allow_anonymous: allow_anonymous
41
52
  )
42
53
  end
43
54
 
@@ -47,7 +58,20 @@ module Sequel
47
58
  # @param comment [String, nil] Description of what this policy does
48
59
  # @param cacheable [Boolean] Whether results can be cached (default: true)
49
60
  # @param single_match [Boolean] Whether only one subject/actor pair can match (default: false)
50
- def setup(policy_name: nil, comment: nil, cacheable: true, single_match: false)
61
+ # @param cache_by [Symbol, Array<Symbol>, nil] Override the cache-key
62
+ # dimensions. By default the key is derived from the policy's arity
63
+ # (all inputs the policy receives). Pass a subset of
64
+ # `:actor, :subject, :direct_object` to cache by only those — useful
65
+ # when the policy ignores inputs it nominally receives (e.g. an
66
+ # "is-admin" check that takes `(actor, subject)` but only examines
67
+ # actor should use `cache_by: :actor` to share a single entry across
68
+ # subjects).
69
+ # @param allow_anonymous [Boolean] If true, skip the auto-deny that
70
+ # normally fires when a policy of arity >= 1 is evaluated for an
71
+ # anonymous viewer (nil actor). Use for state-gate policies that
72
+ # ignore the actor and decide purely on subject state.
73
+ def setup(policy_name: nil, comment: nil, cacheable: true, single_match: false, cache_by: nil,
74
+ allow_anonymous: false)
51
75
  raise 'Privacy Policy is frozen' if @frozen
52
76
 
53
77
  @cacheable = cacheable
@@ -55,6 +79,8 @@ module Sequel
55
79
  @comment = comment
56
80
  @frozen = true
57
81
  @single_match = single_match
82
+ @cache_by = normalize_cache_by(cache_by)
83
+ @allow_anonymous = allow_anonymous
58
84
  self
59
85
  end
60
86
 
@@ -69,6 +95,32 @@ module Sequel
69
95
  def single_match?
70
96
  @single_match || false
71
97
  end
98
+
99
+ sig { returns(T.nilable(T::Array[Symbol])) }
100
+ def cache_by
101
+ @cache_by
102
+ end
103
+
104
+ sig { returns(T::Boolean) }
105
+ def allow_anonymous?
106
+ @allow_anonymous || false
107
+ end
108
+
109
+ private
110
+
111
+ sig { params(val: T.nilable(T.any(Symbol, T::Array[Symbol]))).returns(T.nilable(T::Array[Symbol])) }
112
+ def normalize_cache_by(val)
113
+ return nil if val.nil?
114
+
115
+ keys = Array(val).map(&:to_sym)
116
+ invalid = keys - VALID_CACHE_BY
117
+ unless invalid.empty?
118
+ raise ArgumentError,
119
+ "Invalid cache_by key(s): #{invalid.inspect}. Valid keys: #{VALID_CACHE_BY.inspect}"
120
+ end
121
+
122
+ keys
123
+ end
72
124
  end
73
125
  end
74
126
  end
@@ -31,21 +31,30 @@ module Sequel
31
31
  # @param comment [String, nil] Human-readable description
32
32
  # @param cacheable [Boolean] Whether results can be cached (default: true)
33
33
  # @param single_match [Boolean] Whether only one subject/actor can match (default: false)
34
+ # @param cache_by [Symbol, Array<Symbol>, nil] Override cache-key
35
+ # dimensions. See Sequel::Privacy::Policy#setup for details.
36
+ # @param allow_anonymous [Boolean] Skip auto-deny for nil actor.
37
+ # See Sequel::Privacy::Policy#setup for details.
34
38
  sig do
35
39
  params(
36
40
  name: Symbol,
37
41
  lam: Proc,
38
42
  comment: T.nilable(String),
39
43
  cacheable: T::Boolean,
40
- single_match: T::Boolean
44
+ single_match: T::Boolean,
45
+ cache_by: T.nilable(T.any(Symbol, T::Array[Symbol])),
46
+ allow_anonymous: T::Boolean
41
47
  ).void
42
48
  end
43
- def policy(name, lam, comment = nil, cacheable: true, single_match: false)
49
+ def policy(name, lam, comment = nil, cacheable: true, single_match: false, cache_by: nil,
50
+ allow_anonymous: false)
44
51
  p = Policy.new(&lam).setup(
45
52
  policy_name: name,
46
53
  comment: comment,
47
54
  cacheable: cacheable,
48
- single_match: single_match
55
+ single_match: single_match,
56
+ cache_by: cache_by,
57
+ allow_anonymous: allow_anonymous
49
58
  )
50
59
  const_set(name, p)
51
60
  end
@@ -3,6 +3,6 @@
3
3
 
4
4
  module Sequel
5
5
  module Privacy
6
- VERSION = '0.2.1'
6
+ VERSION = '0.4'
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sequel-privacy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.1
4
+ version: '0.4'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Austin Bales