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 +4 -4
- data/README.md +47 -32
- data/lib/sequel/plugins/privacy.rb +88 -26
- data/lib/sequel/privacy/built_in_policies.rb +1 -1
- data/lib/sequel/privacy/enforcer.rb +23 -8
- data/lib/sequel/privacy/policy.rb +60 -8
- data/lib/sequel/privacy/policy_dsl.rb +12 -3
- data/lib/sequel/privacy/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8307cc7016667794361e143ba1a17735d2c189079af884a9928df3f67450fed1
|
|
4
|
+
data.tar.gz: f6ae84ecd2b0627deeb998d69cc7f38ec73313b5851b4e84b4367335ab6e7b06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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
|
-
|
|
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, ->(
|
|
44
|
+
|
|
45
|
+
policy :AllowMembers, ->(actor) {
|
|
43
46
|
allow if actor.is_role?(:member)
|
|
44
47
|
}, cacheable: true
|
|
45
48
|
|
|
46
|
-
policy :AllowSelf, ->(
|
|
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, ->(
|
|
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
|
|
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, ->(
|
|
132
|
+
policy :AllowAdmins, ->(actor) {
|
|
124
133
|
allow if actor.is_role?(:admin)
|
|
125
134
|
}
|
|
126
135
|
|
|
127
|
-
policy :AllowOwner, ->(
|
|
136
|
+
policy :AllowOwner, ->(actor, subject) {
|
|
128
137
|
allow if subject.owner_id == actor.id
|
|
129
138
|
}
|
|
130
139
|
|
|
131
|
-
policy :AllowIfDirectObjectIsActor, ->(
|
|
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, ->
|
|
153
|
+
|
|
154
|
+
policy :AllowIfOpen, ->(_actor, subject) {
|
|
146
155
|
allow if subject.open?
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
policy :AllowIfMember, ->
|
|
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
|
|
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, ->(
|
|
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, ->(
|
|
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 `(
|
|
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, ->(
|
|
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, ->(
|
|
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, ->(
|
|
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
|
|
195
|
-
#
|
|
196
|
-
#
|
|
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
|
-
|
|
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
|
|
742
|
-
#
|
|
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
|
-
->(
|
|
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,
|
|
116
|
+
[policy, actor, viewer_context].hash
|
|
105
117
|
when 2
|
|
106
|
-
[policy,
|
|
118
|
+
[policy, actor, subject, viewer_context].hash
|
|
107
119
|
else
|
|
108
|
-
[policy,
|
|
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
|
-
#
|
|
215
|
-
|
|
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(
|
|
238
|
+
Actions.evaluate(actor, &policy)
|
|
224
239
|
when 2
|
|
225
|
-
Actions.evaluate(
|
|
240
|
+
Actions.evaluate(actor, subject, &policy)
|
|
226
241
|
else
|
|
227
|
-
Actions.evaluate(
|
|
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
|
|
9
|
-
# - 0 args: -> { allow }
|
|
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: ->(
|
|
12
|
-
# - 3 args: ->(
|
|
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
|
-
|
|
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
|