sequel-privacy 0.3 → 0.5

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: fd27576a058e95f92fd4c50fc1f5da0e058d0b7f93c3f114b88757a08651dd29
4
- data.tar.gz: f429f5fc246fcd7cb043170a8166b7bc06fa04125aa4c89d6fe196b4c5bead31
3
+ metadata.gz: 8307cc7016667794361e143ba1a17735d2c189079af884a9928df3f67450fed1
4
+ data.tar.gz: f6ae84ecd2b0627deeb998d69cc7f38ec73313b5851b4e84b4367335ab6e7b06
5
5
  SHA512:
6
- metadata.gz: c58b5116d0b2a0ac0acf829612cdf46ee3ec1ef08879d189bbf94170248ac8cbf6a540601de6794bf6f03086e5af4e2f555291ac5d2de22a5405f1fb61ab066a
7
- data.tar.gz: bf580a74fcb95fc328f7d0564877804ef642783442254af86d436ae853b89d88f71cd5abb6cff95ba56ed38992261557049350081bad9d35d63a80ce7ebbde96
6
+ metadata.gz: ff39c00cfd1df723a53bca2a75a3ab6fd3657e1475f9f53c48f369de686c3c81facea4af80325b6acfa23e409e6e5fe71b5794ed9c31fad7300f19727f25e7e7
7
+ data.tar.gz: fa3124720cb8a285a1033fc3923d3df5f61d04685585394d94614d9bc7331ada3f8863335e91c0198229bfc7238e827b004de2cbed2c88a06fd762328753f8b2
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
  ```
@@ -191,12 +191,16 @@ module Sequel
191
191
  :"#{self}_privacy_vc"
192
192
  end
193
193
 
194
- # Override Sequel's call method - this is the lowest-level instantiation point
195
- # for ALL database-loaded records. Every path goes through here:
196
- # - Model[id], Model.first, Model.all, associations, etc.
194
+ # Override Sequel's call method to act as a strict-mode gate.
195
+ # Every database-loaded record flows through here (Model[id],
196
+ # Model.first, Model.all, associations, ...). This checks that a
197
+ # VC is in scope (or that the class opted out via
198
+ # allow_unsafe_access!) and defers VC attachment and :view
199
+ # filtering to DatasetMethods#row_proc — those are per-row
200
+ # concerns and have context-dependent bypasses (policy
201
+ # evaluation, eager-load attachment) that don't belong here.
197
202
  sig { params(values: T.untyped).returns(T.nilable(Sequel::Model)) }
198
203
  def call(values)
199
- # Check if we're in a VC context (thread-local set by for_vc)
200
204
  vc = Thread.current[privacy_vc_key]
201
205
 
202
206
  unless vc || allow_unsafe_access?
@@ -204,25 +208,7 @@ module Sequel
204
208
  "#{self} requires a ViewerContext. Use #{self}.for_vc(vc) or call #{self}.allow_unsafe_access!"
205
209
  end
206
210
 
207
- # Create the instance via parent chain
208
- instance = super
209
-
210
- # Attach VC if present
211
- if vc && instance
212
- instance.instance_variable_set(:@viewer_context, vc)
213
-
214
- # During nested policy evaluation, return raw rows so the outer
215
- # policy can traverse data (e.g. checking membership) without
216
- # recursive :view filtering.
217
- return instance if Sequel::Privacy::Enforcer.in_policy_eval?
218
-
219
- unless T.cast(instance, InstanceMethods).allow?(vc, :view)
220
- Sequel::Privacy.logger&.debug { "Privacy denied :view on #{self}[#{instance.pk}]" }
221
- return nil
222
- end
223
- end
224
-
225
- instance
211
+ super
226
212
  end
227
213
 
228
214
  # ─────────────────────────────────────────────────────────────────────
@@ -410,6 +396,8 @@ module Sequel
410
396
  # Override Sequel's associate method to wrap associations with privacy checks
411
397
  sig { params(type: Symbol, name: Symbol, opts: T.untyped, block: T.untyped).returns(T.untyped) }
412
398
  def associate(type, name, opts = {}, &block)
399
+ opts = _inject_privacy_eager_block(opts)
400
+
413
401
  # Call original to create the association
414
402
  result = super
415
403
 
@@ -426,6 +414,26 @@ module Sequel
426
414
  result
427
415
  end
428
416
 
417
+ # Wrap the association's eager-load dataset with for_vc() so the
418
+ # child rows are materialized with the current viewer context.
419
+ # The VC is propagated via a thread-local set by DatasetMethods#all
420
+ # so that it's only applied during eager loading, not during the
421
+ # lazy association reader path (which has its own handling).
422
+ sig { params(opts: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
423
+ def _inject_privacy_eager_block(opts)
424
+ original = opts[:eager_block]
425
+ wrapped = proc do |ds|
426
+ ds = original.call(ds) if original
427
+ vc = Thread.current[DatasetMethods::EAGER_VC_KEY]
428
+ if vc && T.unsafe(ds).model.respond_to?(:privacy_vc_key)
429
+ T.unsafe(ds).for_vc(vc)
430
+ else
431
+ ds
432
+ end
433
+ end
434
+ opts.merge(eager_block: wrapped)
435
+ end
436
+
429
437
  private
430
438
 
431
439
  sig { params(name: Symbol).void }
@@ -732,14 +740,27 @@ module Sequel
732
740
  has_attached_class!(:out)
733
741
  requires_ancestor { Sequel::Dataset }
734
742
 
743
+ # Thread-local key for propagating the current VC to eager-load
744
+ # datasets via the :eager_block injected in ClassMethods#associate.
745
+ EAGER_VC_KEY = :sequel_privacy_eager_vc
746
+
735
747
  # Attach viewer context to dataset for privacy enforcement on materialization
736
748
  sig { params(vc: Sequel::Privacy::ViewerContext).returns(Sequel::Dataset) }
737
749
  def for_vc(vc)
738
750
  clone(viewer_context: vc)
739
751
  end
740
752
 
741
- # Override row_proc to wrap Model.call with thread-local VC.
742
- # This is the single integration point that covers all iteration methods.
753
+ # Override row_proc to wrap Model.call with the full per-row
754
+ # privacy pipeline: set the thread-local VC so Model.call's
755
+ # strict-mode gate passes, attach the VC to the instance, and
756
+ # apply the :view filter — with two bypasses for materialization
757
+ # contexts where filtering would be wrong or break callers:
758
+ # - in_policy_eval?: policies that traverse protected data
759
+ # need raw rows so their checks (e.g. membership) aren't
760
+ # short-circuited by recursive :view filtering.
761
+ # - EAGER_VC_KEY: Sequel's eager-load attachment block
762
+ # dereferences each record to bucket by FK and would crash
763
+ # on nils; the association reader filters at read time.
743
764
  sig { returns(T.untyped) }
744
765
  def row_proc
745
766
  vc = opts[:viewer_context]
@@ -751,10 +772,23 @@ module Sequel
751
772
  old_vc = Thread.current[vc_key]
752
773
  Thread.current[vc_key] = vc
753
774
  begin
754
- model_class.(values)
775
+ instance = model_class.(values)
755
776
  ensure
756
777
  Thread.current[vc_key] = old_vc
757
778
  end
779
+
780
+ next nil if instance.nil?
781
+
782
+ instance.instance_variable_set(:@viewer_context, vc)
783
+ next instance if Sequel::Privacy::Enforcer.in_policy_eval?
784
+ next instance if Thread.current[EAGER_VC_KEY]
785
+
786
+ if T.cast(instance, InstanceMethods).allow?(vc, :view)
787
+ instance
788
+ else
789
+ Sequel::Privacy.logger&.debug { "Privacy denied :view on #{model_class}[#{instance.pk}]" }
790
+ nil
791
+ end
758
792
  end
759
793
  end
760
794
 
@@ -765,6 +799,34 @@ module Sequel
765
799
  opts[:viewer_context] ? results.compact : results
766
800
  end
767
801
 
802
+ # Sequel calls post_load after rows are fetched but before any
803
+ # user block. Model's override of post_load triggers eager_load
804
+ # here. Set the thread-local VC around that call so each
805
+ # association's injected :eager_block can wrap its child dataset
806
+ # with for_vc. Children are then materialized with VC attached
807
+ # but without :view filtering (see Model.call), so Sequel's
808
+ # attachment block doesn't choke on nils; the accessor wrapper
809
+ # filters at read time.
810
+ #
811
+ # Parents filtered to nil by the :view policy must be dropped
812
+ # before eager_load runs — its attachment code dereferences each
813
+ # record, which nil would break.
814
+ sig { params(all_records: T.untyped).returns(T.untyped) }
815
+ def post_load(all_records)
816
+ vc = opts[:viewer_context]
817
+ return super unless vc && opts[:eager]
818
+
819
+ all_records.compact!
820
+
821
+ old = Thread.current[EAGER_VC_KEY]
822
+ Thread.current[EAGER_VC_KEY] = vc
823
+ begin
824
+ super
825
+ ensure
826
+ Thread.current[EAGER_VC_KEY] = old
827
+ end
828
+ end
829
+
768
830
  # Create a new model instance with the viewer context attached
769
831
  sig { params(values: T::Hash[Symbol, T.untyped]).returns(T.attached_class) }
770
832
  def new(values = {})
@@ -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.3'
6
+ VERSION = '0.5'
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.3'
4
+ version: '0.5'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Austin Bales