sequel-privacy 0.1.0 → 0.2.1
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 +105 -55
- data/lib/sequel/plugins/privacy.rb +115 -129
- data/lib/sequel/privacy/actions.rb +33 -23
- data/lib/sequel/privacy/built_in_policies.rb +1 -1
- data/lib/sequel/privacy/enforcer.rb +48 -31
- data/lib/sequel/privacy/policy.rb +3 -2
- data/lib/sequel/privacy/policy_dsl.rb +17 -1
- data/lib/sequel/privacy/version.rb +1 -1
- data/lib/sequel/privacy/viewer_context.rb +0 -16
- metadata +1 -2
- data/rbi/sequel_privacy.rbi +0 -66
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04b5728dab12a15490535725e9458d1039e17049f47e839e724b5464b1e39ee5
|
|
4
|
+
data.tar.gz: ddb26ed4c76c15b672043d2ac32ff4573b791befc8c9f3b1d0405e0c4ddcd6ba
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1b235a397d24fbfea16f557628554b5e64352a2872b54d9b51b36e81d13fe659be3b1a011e256626db7025dc7527b33e891866c116e4a1dbe4a03821209401c6
|
|
7
|
+
data.tar.gz: 105bb81dc55bb0970a224682c7ab30117758e882db8acac968802da1ed0fa06bd29145350d9de196dda2f6b5d0f81b7c58433e1107317ab8f4b7d297a5ee2c53
|
data/README.md
CHANGED
|
@@ -58,6 +58,9 @@ end
|
|
|
58
58
|
```ruby
|
|
59
59
|
class Member < Sequel::Model
|
|
60
60
|
plugin :privacy
|
|
61
|
+
|
|
62
|
+
# Include this module if this model can be used to create a viewer context.
|
|
63
|
+
include Sequel::Privacy::IActor
|
|
61
64
|
|
|
62
65
|
privacy do
|
|
63
66
|
# Define who can view this model; be strategic about the order of your policies so that You
|
|
@@ -77,29 +80,36 @@ The `privacy` block provides:
|
|
|
77
80
|
- `field :name, *policies` - Protect a field (auto-creates `:view_#{field}` policy)
|
|
78
81
|
- `finalize!` - Prevent further modifications to privacy settings
|
|
79
82
|
|
|
80
|
-
`AlwaysDeny` is automatically appended to all policy chains
|
|
83
|
+
`AlwaysDeny` is automatically appended to all policy chains if you don't include it, but it's better to add it explictly.
|
|
84
|
+
This behavior may change.
|
|
81
85
|
|
|
82
86
|
### 3. Query with Privacy Enforcement
|
|
83
87
|
|
|
84
88
|
```ruby
|
|
85
|
-
# Create a viewer context
|
|
86
|
-
|
|
89
|
+
# Create a viewer context; one viewer context for each request.
|
|
90
|
+
# In a Roda app, you could do this at the top of your routing tree,
|
|
91
|
+
# in Sinatra you could do this in a `before` filter. See the dedicated
|
|
92
|
+
# section below for more information about VCs.
|
|
93
|
+
current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
|
|
87
94
|
|
|
88
|
-
#
|
|
89
|
-
members = Member.for_vc(vc).where(org_id:
|
|
95
|
+
# Results will filter out records that your VC can't see.
|
|
96
|
+
members = Member.for_vc(vc).where(org_id: current_user.org_id).all
|
|
90
97
|
|
|
91
|
-
#
|
|
92
|
-
|
|
93
|
-
|
|
98
|
+
# But DON'T rely on the privacy checker in place of refining your query
|
|
99
|
+
# with privacy/permissions in-mind.
|
|
100
|
+
my_groups = Group.for_vc(vc).all # DONT: This results on tons of records being returned, processed and filtered for no reason.
|
|
101
|
+
my_groups = Group.for_vc(vc).where(creator: current_user).all # DO
|
|
94
102
|
|
|
95
|
-
#
|
|
103
|
+
# Field-level privacy policies will be enforced on access.
|
|
96
104
|
member.email # => nil if :view_email denies
|
|
97
105
|
member.phone # => nil if :view_phone denies
|
|
98
106
|
```
|
|
99
107
|
|
|
100
108
|
## Policy Definition
|
|
101
109
|
|
|
102
|
-
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.
|
|
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.
|
|
111
|
+
|
|
112
|
+
Policies accept up to three parameters: `actor`, `subject` & `actor` or `subject`, `actor` and `direct_object`.
|
|
103
113
|
|
|
104
114
|
|
|
105
115
|
```ruby
|
|
@@ -114,24 +124,34 @@ policy :AllowAdmins, ->(_subject, actor) {
|
|
|
114
124
|
allow if actor.is_role?(:admin)
|
|
115
125
|
}
|
|
116
126
|
|
|
117
|
-
policy :AllowOwner, ->(
|
|
127
|
+
policy :AllowOwner, ->(_subject, actor) {
|
|
118
128
|
allow if subject.owner_id == actor.id
|
|
119
129
|
}
|
|
120
130
|
|
|
121
|
-
policy :
|
|
122
|
-
allow if actor.id ==
|
|
131
|
+
policy :AllowIfDirectObjectIsActor, ->(_subject, actor, direct_object) {
|
|
132
|
+
allow if actor.id == direct_object.id
|
|
123
133
|
}
|
|
124
134
|
|
|
125
|
-
policy :AllowSelfRemove, ->(_group, actor, target_user) {
|
|
126
|
-
allow if actor.id == target_user.id
|
|
127
|
-
}
|
|
128
135
|
```
|
|
129
136
|
|
|
130
|
-
|
|
137
|
+
If you have lots of different objects and want to make your policies more specific, you can define policies in
|
|
138
|
+
different modules.
|
|
131
139
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
140
|
+
```ruby
|
|
141
|
+
module P
|
|
142
|
+
module Groups
|
|
143
|
+
extend Sequel::Privacy::PolicyDSL
|
|
144
|
+
|
|
145
|
+
policy :AllowIfOpen, -> (subject, _actor) {
|
|
146
|
+
allow if subject.open?
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
policy :AllowIfMember, -> (subject, actor) {
|
|
150
|
+
allow if subject.includes_member? actor
|
|
151
|
+
}
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
```
|
|
135
155
|
|
|
136
156
|
### Policy Options
|
|
137
157
|
|
|
@@ -151,7 +171,13 @@ policy :MyPolicy, ->() { ... },
|
|
|
151
171
|
Use `all()` to require multiple conditions:
|
|
152
172
|
|
|
153
173
|
```ruby
|
|
154
|
-
policy :
|
|
174
|
+
policy :AllowAddSelfToOpenGroup, ->(subject, actor, direct_object) {
|
|
175
|
+
all(
|
|
176
|
+
P::AllowIfGroupIsOpen
|
|
177
|
+
P::AllowIfDirectObjectIsActor
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
policy :AllowRemoveSelf, ->(subject, actor, direct_object) {
|
|
155
181
|
all(
|
|
156
182
|
P::AllowIfIncludesMember,
|
|
157
183
|
P::AllowIfDirectObjectIsActor
|
|
@@ -170,13 +196,14 @@ Anonymous VCs are useful for logged out users, and can check that their access i
|
|
|
170
196
|
that are meant to be fully public.
|
|
171
197
|
|
|
172
198
|
Omniscient VCs are most useful when your application needs to see an object that a user cannot for some reason.
|
|
173
|
-
Handle them with care. Login is the most salient example.
|
|
199
|
+
Handle them with care. Login is the most salient example (see note below for more detail).
|
|
174
200
|
|
|
175
201
|
All-Powerful VCs bypass all privacy checks and are used in situations where the system needs unfettered access
|
|
176
202
|
to models. In a production setting, your application should prohibit raw Database access outside of the privacy-aware
|
|
177
203
|
system, so these VCs give you an escape hatch for things like scripts while also keeping an audit trail.
|
|
178
204
|
|
|
179
205
|
`omniscient` and `all_powerful` require a reason (symbol) for audit logging.
|
|
206
|
+
You could also create lint rules that prevent the casual creation of these viewer contexts.
|
|
180
207
|
|
|
181
208
|
```ruby
|
|
182
209
|
# Standard viewer (most common)
|
|
@@ -199,6 +226,36 @@ current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
|
|
|
199
226
|
admin_vc = Sequel::Privacy::ViewerContext.all_powerful(:admin_migration)
|
|
200
227
|
```
|
|
201
228
|
|
|
229
|
+
### A Note Login & Authenticated Users
|
|
230
|
+
|
|
231
|
+
If your User or equivalent model is privacy-aware *and* is protected by
|
|
232
|
+
policies that would complicating fetching (or login), then you will have
|
|
233
|
+
trouble creating a `current_user` for an `ActorVC`.
|
|
234
|
+
|
|
235
|
+
In both cases you can use an `OmniscientVC` to make your initial User query.
|
|
236
|
+
|
|
237
|
+
```ruby
|
|
238
|
+
before do
|
|
239
|
+
if session[:user_id]
|
|
240
|
+
current_user = Sequel::Privacy::ViewerContext.omniscient(:session).then {|vc| User.for_vc(vc)[session[:user_id]] }
|
|
241
|
+
current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
|
|
242
|
+
else
|
|
243
|
+
current_user = nil
|
|
244
|
+
current_vc = Sequel::Privacy::ViewerContext.anonymous
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
post '/auth/password' do
|
|
249
|
+
user = Sequel::Privacy::ViewerContext.omniscient(:login).then {|vc| User.for_vc(vc).first(email: params[:email]) }
|
|
250
|
+
|
|
251
|
+
pass unless user
|
|
252
|
+
pass unless user.password == params[:password]
|
|
253
|
+
|
|
254
|
+
session[:user_id] = user.id
|
|
255
|
+
redirect '/'
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
202
259
|
## Mutation Enforcement
|
|
203
260
|
|
|
204
261
|
When a viewer context is attached, mutations are automatically checked:
|
|
@@ -251,7 +308,7 @@ The `association` block supports three actions:
|
|
|
251
308
|
- `:remove` - Wraps `remove_*` method (e.g., `remove_member`)
|
|
252
309
|
- `:remove_all` - Wraps `remove_all_*` method (e.g., `remove_all_members`)
|
|
253
310
|
|
|
254
|
-
Association policies
|
|
311
|
+
Association policies receive `(subject, actor, direct_object)`:
|
|
255
312
|
- `subject` - The model instance (e.g., the group)
|
|
256
313
|
- `actor` - The current user from the viewer context
|
|
257
314
|
- `direct_object` - The object being added/removed (e.g., the user being added to the group)
|
|
@@ -260,17 +317,17 @@ For `remove_all`, the direct object is `nil` since there's no specific target.
|
|
|
260
317
|
|
|
261
318
|
```ruby
|
|
262
319
|
# Allow users to add/remove themselves
|
|
263
|
-
policy :AllowSelfJoin, ->(
|
|
264
|
-
allow if actor.id ==
|
|
320
|
+
policy :AllowSelfJoin, ->(_subject, actor, direct_object) {
|
|
321
|
+
allow if actor.id == direct_object.id
|
|
265
322
|
}, single_match: true
|
|
266
323
|
|
|
267
|
-
policy :AllowSelfRemove, ->(
|
|
268
|
-
allow if actor.id ==
|
|
324
|
+
policy :AllowSelfRemove, ->(_subject, actor, direct_object) {
|
|
325
|
+
allow if actor.id == direct_object.id
|
|
269
326
|
}, single_match: true
|
|
270
327
|
|
|
271
328
|
# Allow group admins to add/remove anyone
|
|
272
|
-
policy :AllowGroupAdmin, ->(
|
|
273
|
-
allow if
|
|
329
|
+
policy :AllowGroupAdmin, ->(subject, actor, direct_object) {
|
|
330
|
+
allow if subject.includes_admin?(actor)
|
|
274
331
|
}
|
|
275
332
|
```
|
|
276
333
|
|
|
@@ -292,11 +349,6 @@ group.remove_all_members
|
|
|
292
349
|
group.add_member(other_user) # Raises Sequel::Privacy::Unauthorized
|
|
293
350
|
```
|
|
294
351
|
|
|
295
|
-
Association privacy methods:
|
|
296
|
-
- Require a viewer context (raises `MissingViewerContext` if missing)
|
|
297
|
-
- Deny operations with `OmniscientVC` (read-only context cannot mutate)
|
|
298
|
-
- Work with both `one_to_many` and `many_to_many` associations
|
|
299
|
-
|
|
300
352
|
### Exception Types
|
|
301
353
|
|
|
302
354
|
- `Sequel::Privacy::Unauthorized` - Action denied at the record level
|
|
@@ -305,7 +357,9 @@ Association privacy methods:
|
|
|
305
357
|
|
|
306
358
|
## Logging
|
|
307
359
|
|
|
308
|
-
Configure a logger to see policy evaluation
|
|
360
|
+
Configure a logger to see policy evaluation. It will show the evaluation results
|
|
361
|
+
(ALLOW, DENY, PASS) as well as cache hits/optimizations and note when privacy
|
|
362
|
+
is bypassed by an APVC or an OmniVC.
|
|
309
363
|
|
|
310
364
|
```ruby
|
|
311
365
|
Sequel::Privacy.logger = Logger.new(STDOUT)
|
|
@@ -313,12 +367,6 @@ Sequel::Privacy.logger = Logger.new(STDOUT)
|
|
|
313
367
|
Sequel::Privacy.logger = SemanticLogger['Privacy']
|
|
314
368
|
```
|
|
315
369
|
|
|
316
|
-
Log output shows:
|
|
317
|
-
- Policy evaluation results (ALLOW/DENY/PASS)
|
|
318
|
-
- Cache hits
|
|
319
|
-
- Single-match optimizations
|
|
320
|
-
- All-powerful/omniscient context bypasses
|
|
321
|
-
|
|
322
370
|
## Cache Management
|
|
323
371
|
|
|
324
372
|
Policy results are cached per-request to avoid redundant evaluation. Clear between requests:
|
|
@@ -346,7 +394,8 @@ Sequel::Privacy.single_matches.clear
|
|
|
346
394
|
|
|
347
395
|
## Actor Interface
|
|
348
396
|
|
|
349
|
-
Your user/member model must implement `Sequel::Privacy::IActor
|
|
397
|
+
Your user/member model must include and implement `Sequel::Privacy::IActor`.
|
|
398
|
+
This will be runtime checked by Sorbet.
|
|
350
399
|
|
|
351
400
|
```ruby
|
|
352
401
|
class Member < Sequel::Model
|
|
@@ -358,17 +407,13 @@ class Member < Sequel::Model
|
|
|
358
407
|
end
|
|
359
408
|
```
|
|
360
409
|
|
|
361
|
-
The interface requires:
|
|
362
|
-
- `id` - Returns the actor's unique identifier
|
|
363
|
-
|
|
364
|
-
You can add additional methods like `is_role?` for use in your policies, but they are not required by the interface.
|
|
365
|
-
|
|
366
410
|
## Policy Inheritance
|
|
367
411
|
|
|
368
412
|
Child classes inherit privacy policies from their parents:
|
|
369
413
|
|
|
370
414
|
```ruby
|
|
371
415
|
class User < Sequel::Model
|
|
416
|
+
include Sequel::Privacy::IActor
|
|
372
417
|
plugin :privacy
|
|
373
418
|
|
|
374
419
|
privacy do
|
|
@@ -386,14 +431,25 @@ end
|
|
|
386
431
|
|
|
387
432
|
## Built-in Policies
|
|
388
433
|
|
|
389
|
-
- `Sequel::Privacy::BuiltInPolicies::AlwaysDeny` - Always denies
|
|
434
|
+
- `Sequel::Privacy::BuiltInPolicies::AlwaysDeny` - Always denies; add it to the end of your policy chains.
|
|
390
435
|
- `Sequel::Privacy::BuiltInPolicies::AlwaysAllow` - Always allows
|
|
391
436
|
- `Sequel::Privacy::BuiltInPolicies::PassAndLog` - Passes with a log message (useful for debugging)
|
|
392
437
|
|
|
393
438
|
|
|
394
439
|
## Type Safety (Sorbet)
|
|
395
440
|
|
|
396
|
-
The gem is mostly fully typed with Sorbet. Type definitions are provided for all public APIs.
|
|
441
|
+
The gem is mostly fully typed with Sorbet. Type definitions are provided for all public APIs. To ensure
|
|
442
|
+
that Tapioca imports the required definitions, you may need to add this to your `sorbet/tapioca/require.rb`:
|
|
443
|
+
|
|
444
|
+
```ruby
|
|
445
|
+
require "sequel-privacy"
|
|
446
|
+
require "sequel/plugins/privacy"
|
|
447
|
+
|
|
448
|
+
# Force Tapioca to see the plugin modules by applying them to a dummy class
|
|
449
|
+
Class.new(Sequel::Model) do
|
|
450
|
+
plugin :privacy
|
|
451
|
+
end
|
|
452
|
+
```
|
|
397
453
|
|
|
398
454
|
## AI Statement
|
|
399
455
|
|
|
@@ -401,12 +457,6 @@ The core of this project was written by me (arbales) over the course of 2025 for
|
|
|
401
457
|
manages mailing lists and member information for a social group. Claude assisted substantially with
|
|
402
458
|
extracting it into a Gem and wrote the tests in their entirety.
|
|
403
459
|
|
|
404
|
-
## TODO
|
|
405
|
-
|
|
406
|
-
I'd like to support generation of Sorbet signatures on models that use the plugin so that
|
|
407
|
-
Sorbet users don't have to shim their models to use it. In my own work, I've shimmed my
|
|
408
|
-
base model class, but this isn't ideal.
|
|
409
|
-
|
|
410
460
|
## License
|
|
411
461
|
|
|
412
462
|
MIT
|
|
@@ -37,8 +37,8 @@ module Sequel
|
|
|
37
37
|
extend T::Sig
|
|
38
38
|
|
|
39
39
|
# Called once when plugin first loads on a model
|
|
40
|
-
sig { params(model: T.class_of(Sequel::Model),
|
|
41
|
-
def self.apply(model,
|
|
40
|
+
sig { params(model: T.class_of(Sequel::Model), _opts: T::Hash[Symbol, T.untyped]).void }
|
|
41
|
+
def self.apply(model, _opts = {})
|
|
42
42
|
model.instance_variable_set(:@privacy_policies, {})
|
|
43
43
|
model.instance_variable_set(:@privacy_fields, {})
|
|
44
44
|
model.instance_variable_set(:@privacy_association_policies, {})
|
|
@@ -56,7 +56,10 @@ module Sequel
|
|
|
56
56
|
class AssociationPrivacyDSL
|
|
57
57
|
extend T::Sig
|
|
58
58
|
|
|
59
|
-
sig {
|
|
59
|
+
sig {
|
|
60
|
+
params(model_class: ClassMethods, assoc_name: Symbol,
|
|
61
|
+
policy_resolver: T.proc.params(policies: T::Array[T.untyped]).returns(T::Array[T.untyped])).void
|
|
62
|
+
}
|
|
60
63
|
def initialize(model_class, assoc_name, policy_resolver)
|
|
61
64
|
@model_class = model_class
|
|
62
65
|
@assoc_name = assoc_name
|
|
@@ -68,10 +71,11 @@ module Sequel
|
|
|
68
71
|
sig { params(action: Symbol, policies: T.untyped).void }
|
|
69
72
|
def can(action, *policies)
|
|
70
73
|
unless %i[add remove remove_all].include?(action)
|
|
71
|
-
Kernel.raise ArgumentError,
|
|
74
|
+
Kernel.raise ArgumentError,
|
|
75
|
+
"Association action must be :add, :remove, or :remove_all, got #{action.inspect}"
|
|
72
76
|
end
|
|
73
77
|
|
|
74
|
-
resolved = @policy_resolver.
|
|
78
|
+
resolved = @policy_resolver.(policies)
|
|
75
79
|
@pending_policies[action] ||= []
|
|
76
80
|
T.must(@pending_policies[action]).concat(resolved)
|
|
77
81
|
end
|
|
@@ -80,10 +84,9 @@ module Sequel
|
|
|
80
84
|
sig { void }
|
|
81
85
|
def finalize_association!
|
|
82
86
|
@pending_policies.each do |action, policies|
|
|
83
|
-
|
|
87
|
+
@model_class.register_association_policies(@assoc_name, action, policies, defer_setup: true)
|
|
84
88
|
end
|
|
85
|
-
|
|
86
|
-
T.unsafe(@model_class).setup_association_privacy(@assoc_name)
|
|
89
|
+
@model_class.setup_association_privacy(@assoc_name)
|
|
87
90
|
end
|
|
88
91
|
end
|
|
89
92
|
|
|
@@ -91,7 +94,7 @@ module Sequel
|
|
|
91
94
|
class PrivacyDSL
|
|
92
95
|
extend T::Sig
|
|
93
96
|
|
|
94
|
-
sig { params(model_class:
|
|
97
|
+
sig { params(model_class: ClassMethods).void }
|
|
95
98
|
def initialize(model_class)
|
|
96
99
|
@model_class = model_class
|
|
97
100
|
end
|
|
@@ -100,7 +103,7 @@ module Sequel
|
|
|
100
103
|
sig { params(action: Symbol, policies: T.untyped).void }
|
|
101
104
|
def can(action, *policies)
|
|
102
105
|
resolved = resolve_policies(policies)
|
|
103
|
-
|
|
106
|
+
@model_class.register_policies(action, resolved)
|
|
104
107
|
end
|
|
105
108
|
|
|
106
109
|
# Define a protected field with its policies
|
|
@@ -108,8 +111,8 @@ module Sequel
|
|
|
108
111
|
def field(name, *policies)
|
|
109
112
|
resolved = resolve_policies(policies)
|
|
110
113
|
policy_name = :"view_#{name}"
|
|
111
|
-
|
|
112
|
-
|
|
114
|
+
@model_class.register_policies(policy_name, resolved)
|
|
115
|
+
@model_class.register_protected_field(name, policy_name)
|
|
113
116
|
end
|
|
114
117
|
|
|
115
118
|
# Define policies for an association
|
|
@@ -120,7 +123,7 @@ module Sequel
|
|
|
120
123
|
# can :remove, AllowGroupAdmin, AllowSelfRemove
|
|
121
124
|
# can :remove_all, AllowGroupAdmin
|
|
122
125
|
# end
|
|
123
|
-
sig { params(name: Symbol, block: T.proc.void).void }
|
|
126
|
+
sig { params(name: Symbol, block: T.proc.bind(AssociationPrivacyDSL).void).void }
|
|
124
127
|
def association(name, &block)
|
|
125
128
|
resolver = ->(policies) { resolve_policies(policies) }
|
|
126
129
|
dsl = AssociationPrivacyDSL.new(@model_class, name, resolver)
|
|
@@ -131,7 +134,7 @@ module Sequel
|
|
|
131
134
|
# Finalize privacy settings (no more changes allowed)
|
|
132
135
|
sig { void }
|
|
133
136
|
def finalize!
|
|
134
|
-
|
|
137
|
+
@model_class.finalize_privacy!
|
|
135
138
|
end
|
|
136
139
|
|
|
137
140
|
private
|
|
@@ -198,7 +201,7 @@ module Sequel
|
|
|
198
201
|
|
|
199
202
|
unless vc || allow_unsafe_access?
|
|
200
203
|
Kernel.raise Sequel::Privacy::MissingViewerContext,
|
|
201
|
-
|
|
204
|
+
"#{self} requires a ViewerContext. Use #{self}.for_vc(vc) or call #{self}.allow_unsafe_access!"
|
|
202
205
|
end
|
|
203
206
|
|
|
204
207
|
# Create the instance via parent chain
|
|
@@ -208,12 +211,14 @@ module Sequel
|
|
|
208
211
|
if vc && instance
|
|
209
212
|
instance.instance_variable_set(:@viewer_context, vc)
|
|
210
213
|
|
|
211
|
-
#
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
|
217
222
|
end
|
|
218
223
|
end
|
|
219
224
|
|
|
@@ -255,7 +260,7 @@ module Sequel
|
|
|
255
260
|
# can :edit, P::AllowSelf, P::AllowAdmins
|
|
256
261
|
# field :email, P::AllowSelf
|
|
257
262
|
# end
|
|
258
|
-
sig { params(block: T.proc.void).void }
|
|
263
|
+
sig { params(block: T.proc.bind(PrivacyDSL).void).void }
|
|
259
264
|
def privacy(&block)
|
|
260
265
|
if privacy_finalized?
|
|
261
266
|
Kernel.raise Sequel::Privacy::PrivacyAlreadyFinalizedError, "Privacy already finalized for #{self}"
|
|
@@ -290,25 +295,21 @@ module Sequel
|
|
|
290
295
|
|
|
291
296
|
# Override the field getter
|
|
292
297
|
define_method(field) do
|
|
298
|
+
# During nested policy evaluation, return raw value without
|
|
299
|
+
# checking the field's view policy.
|
|
300
|
+
return original_method.bind(self).() if Sequel::Privacy::Enforcer.in_policy_eval?
|
|
301
|
+
|
|
293
302
|
vc = instance_variable_get(:@viewer_context)
|
|
294
303
|
|
|
295
|
-
# Require VC for protected field access
|
|
296
304
|
unless vc
|
|
297
305
|
Kernel.raise Sequel::Privacy::MissingViewerContext,
|
|
298
|
-
|
|
306
|
+
"#{self.class}##{field} requires a ViewerContext"
|
|
299
307
|
end
|
|
300
308
|
|
|
301
|
-
value = original_method.bind(self).
|
|
309
|
+
value = original_method.bind(self).()
|
|
310
|
+
return unless T.cast(self, InstanceMethods).allow?(vc, policy_name)
|
|
302
311
|
|
|
303
|
-
|
|
304
|
-
return value if vc.is_a?(Sequel::Privacy::InternalPolicyEvaluationVC)
|
|
305
|
-
|
|
306
|
-
# Check privacy policy
|
|
307
|
-
if T.unsafe(self).allow?(vc, policy_name)
|
|
308
|
-
value
|
|
309
|
-
else
|
|
310
|
-
nil
|
|
311
|
-
end
|
|
312
|
+
value
|
|
312
313
|
end
|
|
313
314
|
end
|
|
314
315
|
|
|
@@ -316,9 +317,7 @@ module Sequel
|
|
|
316
317
|
# @param defer_setup [Boolean] If true, don't set up wrappers yet (caller will call setup_association_privacy)
|
|
317
318
|
sig { params(assoc_name: Symbol, action: Symbol, policies: T::Array[T.untyped], defer_setup: T::Boolean).void }
|
|
318
319
|
def register_association_policies(assoc_name, action, policies, defer_setup: false)
|
|
319
|
-
if privacy_finalized?
|
|
320
|
-
Kernel.raise "Privacy policies have been finalized for #{self}"
|
|
321
|
-
end
|
|
320
|
+
Kernel.raise "Privacy policies have been finalized for #{self}" if privacy_finalized?
|
|
322
321
|
|
|
323
322
|
privacy_association_policies[assoc_name] ||= {}
|
|
324
323
|
assoc_hash = T.must(privacy_association_policies[assoc_name])
|
|
@@ -342,6 +341,7 @@ module Sequel
|
|
|
342
341
|
# Track which associations have been wrapped to avoid double-wrapping
|
|
343
342
|
@_wrapped_associations ||= T.let({}, T.nilable(T::Hash[Symbol, T::Boolean]))
|
|
344
343
|
return if @_wrapped_associations[assoc_name]
|
|
344
|
+
|
|
345
345
|
@_wrapped_associations[assoc_name] = true
|
|
346
346
|
|
|
347
347
|
# Determine the singular name for method naming
|
|
@@ -363,9 +363,9 @@ module Sequel
|
|
|
363
363
|
|
|
364
364
|
# Wrap remove_all_* method if :remove_all policy exists
|
|
365
365
|
remove_all_policies = assoc_policies[:remove_all]
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
366
|
+
return unless remove_all_policies && method_defined?(:"remove_all_#{reflection[:name]}")
|
|
367
|
+
|
|
368
|
+
_wrap_association_remove_all(assoc_name, reflection[:name], remove_all_policies)
|
|
369
369
|
end
|
|
370
370
|
|
|
371
371
|
# Finalize privacy settings (no more changes allowed)
|
|
@@ -440,30 +440,26 @@ module Sequel
|
|
|
440
440
|
|
|
441
441
|
# Load association with VC context set if available
|
|
442
442
|
obj = if vc && assoc_class.respond_to?(:privacy_vc_key)
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
443
|
+
vc_key = assoc_class.privacy_vc_key
|
|
444
|
+
old_vc = Thread.current[vc_key]
|
|
445
|
+
Thread.current[vc_key] = vc
|
|
446
|
+
begin
|
|
447
|
+
original.bind(self).()
|
|
448
|
+
ensure
|
|
449
|
+
Thread.current[vc_key] = old_vc
|
|
450
|
+
end
|
|
451
|
+
else
|
|
452
|
+
original.bind(self).()
|
|
453
|
+
end
|
|
454
454
|
|
|
455
455
|
return nil unless obj
|
|
456
456
|
return obj unless vc
|
|
457
|
+
return obj if Sequel::Privacy::Enforcer.in_policy_eval?
|
|
457
458
|
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
return obj if vc.is_a?(Sequel::Privacy::InternalPolicyEvaluationVC)
|
|
461
|
-
|
|
462
|
-
# Attach viewer context to associated object
|
|
463
|
-
obj.instance_variable_set(:@viewer_context, vc) if obj.respond_to?(:allow?)
|
|
459
|
+
privacy_aware = obj.is_a?(Sequel::Model) && obj.class.respond_to?(:privacy_vc_key)
|
|
460
|
+
obj.instance_variable_set(:@viewer_context, vc) if privacy_aware
|
|
464
461
|
|
|
465
|
-
|
|
466
|
-
if obj.respond_to?(:allow?) && !obj.allow?(vc, :view)
|
|
462
|
+
if privacy_aware && !T.cast(obj, InstanceMethods).allow?(vc, :view)
|
|
467
463
|
nil
|
|
468
464
|
else
|
|
469
465
|
obj
|
|
@@ -485,29 +481,26 @@ module Sequel
|
|
|
485
481
|
|
|
486
482
|
# Load association with VC context set if available
|
|
487
483
|
objs = if vc && assoc_class.respond_to?(:privacy_vc_key)
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
484
|
+
vc_key = assoc_class.privacy_vc_key
|
|
485
|
+
old_vc = Thread.current[vc_key]
|
|
486
|
+
Thread.current[vc_key] = vc
|
|
487
|
+
begin
|
|
488
|
+
original.bind(self).()
|
|
489
|
+
ensure
|
|
490
|
+
Thread.current[vc_key] = old_vc
|
|
491
|
+
end
|
|
492
|
+
else
|
|
493
|
+
original.bind(self).()
|
|
494
|
+
end
|
|
499
495
|
|
|
500
496
|
return objs unless vc
|
|
497
|
+
return objs if Sequel::Privacy::Enforcer.in_policy_eval?
|
|
501
498
|
|
|
502
|
-
# InternalPolicyEvaluationVC = return raw data (for policy checks like includes_member?)
|
|
503
|
-
# This allows policies to access associations without filtering
|
|
504
|
-
return objs if vc.is_a?(Sequel::Privacy::InternalPolicyEvaluationVC)
|
|
505
|
-
|
|
506
|
-
# Filter array, attaching VC and checking :view policy
|
|
507
499
|
objs.filter_map do |obj|
|
|
508
|
-
obj.
|
|
500
|
+
privacy_aware = obj.is_a?(Sequel::Model) && obj.class.respond_to?(:privacy_vc_key)
|
|
501
|
+
obj.instance_variable_set(:@viewer_context, vc) if privacy_aware
|
|
509
502
|
|
|
510
|
-
if
|
|
503
|
+
if privacy_aware && !T.cast(obj, InstanceMethods).allow?(vc, :view)
|
|
511
504
|
nil
|
|
512
505
|
else
|
|
513
506
|
obj
|
|
@@ -516,8 +509,8 @@ module Sequel
|
|
|
516
509
|
end
|
|
517
510
|
end
|
|
518
511
|
|
|
519
|
-
sig { params(
|
|
520
|
-
def _wrap_association_add(
|
|
512
|
+
sig { params(_assoc_name: Symbol, singular_name: Symbol, policies: T::Array[T.untyped]).void }
|
|
513
|
+
def _wrap_association_add(_assoc_name, singular_name, policies)
|
|
521
514
|
method_name = :"add_#{singular_name}"
|
|
522
515
|
original = instance_method(method_name)
|
|
523
516
|
|
|
@@ -526,12 +519,12 @@ module Sequel
|
|
|
526
519
|
|
|
527
520
|
unless vc
|
|
528
521
|
Kernel.raise Sequel::Privacy::MissingViewerContext,
|
|
529
|
-
|
|
522
|
+
"Cannot #{method_name} without a viewer context"
|
|
530
523
|
end
|
|
531
524
|
|
|
532
525
|
if vc.is_a?(Sequel::Privacy::OmniscientVC)
|
|
533
526
|
Kernel.raise Sequel::Privacy::Unauthorized,
|
|
534
|
-
|
|
527
|
+
"Cannot #{method_name} with OmniscientVC"
|
|
535
528
|
end
|
|
536
529
|
|
|
537
530
|
# Check policy with 3-arity: (subject=self, actor, direct_object=obj)
|
|
@@ -539,15 +532,15 @@ module Sequel
|
|
|
539
532
|
|
|
540
533
|
unless allowed
|
|
541
534
|
Kernel.raise Sequel::Privacy::Unauthorized,
|
|
542
|
-
|
|
535
|
+
"Cannot #{method_name} on #{self.class}"
|
|
543
536
|
end
|
|
544
537
|
|
|
545
|
-
original.bind(self).
|
|
538
|
+
original.bind(self).(obj)
|
|
546
539
|
end
|
|
547
540
|
end
|
|
548
541
|
|
|
549
|
-
sig { params(
|
|
550
|
-
def _wrap_association_remove(
|
|
542
|
+
sig { params(_assoc_name: Symbol, singular_name: Symbol, policies: T::Array[T.untyped]).void }
|
|
543
|
+
def _wrap_association_remove(_assoc_name, singular_name, policies)
|
|
551
544
|
method_name = :"remove_#{singular_name}"
|
|
552
545
|
original = instance_method(method_name)
|
|
553
546
|
|
|
@@ -556,12 +549,12 @@ module Sequel
|
|
|
556
549
|
|
|
557
550
|
unless vc
|
|
558
551
|
Kernel.raise Sequel::Privacy::MissingViewerContext,
|
|
559
|
-
|
|
552
|
+
"Cannot #{method_name} without a viewer context"
|
|
560
553
|
end
|
|
561
554
|
|
|
562
555
|
if vc.is_a?(Sequel::Privacy::OmniscientVC)
|
|
563
556
|
Kernel.raise Sequel::Privacy::Unauthorized,
|
|
564
|
-
|
|
557
|
+
"Cannot #{method_name} with OmniscientVC"
|
|
565
558
|
end
|
|
566
559
|
|
|
567
560
|
# Check policy with 3-arity: (subject=self, actor, direct_object=obj)
|
|
@@ -569,15 +562,15 @@ module Sequel
|
|
|
569
562
|
|
|
570
563
|
unless allowed
|
|
571
564
|
Kernel.raise Sequel::Privacy::Unauthorized,
|
|
572
|
-
|
|
565
|
+
"Cannot #{method_name} on #{self.class}"
|
|
573
566
|
end
|
|
574
567
|
|
|
575
|
-
original.bind(self).
|
|
568
|
+
original.bind(self).(obj)
|
|
576
569
|
end
|
|
577
570
|
end
|
|
578
571
|
|
|
579
|
-
sig { params(
|
|
580
|
-
def _wrap_association_remove_all(
|
|
572
|
+
sig { params(_assoc_name: Symbol, plural_name: Symbol, policies: T::Array[T.untyped]).void }
|
|
573
|
+
def _wrap_association_remove_all(_assoc_name, plural_name, policies)
|
|
581
574
|
method_name = :"remove_all_#{plural_name}"
|
|
582
575
|
original = instance_method(method_name)
|
|
583
576
|
|
|
@@ -586,23 +579,23 @@ module Sequel
|
|
|
586
579
|
|
|
587
580
|
unless vc
|
|
588
581
|
Kernel.raise Sequel::Privacy::MissingViewerContext,
|
|
589
|
-
|
|
582
|
+
"Cannot #{method_name} without a viewer context"
|
|
590
583
|
end
|
|
591
584
|
|
|
592
585
|
if vc.is_a?(Sequel::Privacy::OmniscientVC)
|
|
593
586
|
Kernel.raise Sequel::Privacy::Unauthorized,
|
|
594
|
-
|
|
587
|
+
"Cannot #{method_name} with OmniscientVC"
|
|
595
588
|
end
|
|
596
589
|
|
|
597
590
|
# Check policy with 2-arity: (subject=self, actor) - no direct object for remove_all
|
|
598
|
-
allowed = Sequel::Privacy::Enforcer.enforce(policies, self, vc
|
|
591
|
+
allowed = Sequel::Privacy::Enforcer.enforce(policies, self, vc)
|
|
599
592
|
|
|
600
593
|
unless allowed
|
|
601
594
|
Kernel.raise Sequel::Privacy::Unauthorized,
|
|
602
|
-
|
|
595
|
+
"Cannot #{method_name} on #{self.class}"
|
|
603
596
|
end
|
|
604
597
|
|
|
605
|
-
original.bind(self).
|
|
598
|
+
original.bind(self).()
|
|
606
599
|
end
|
|
607
600
|
end
|
|
608
601
|
end
|
|
@@ -614,21 +607,20 @@ module Sequel
|
|
|
614
607
|
requires_ancestor { Sequel::Model }
|
|
615
608
|
mixes_in_class_methods(ClassMethods)
|
|
616
609
|
|
|
617
|
-
|
|
618
610
|
sig { returns(T.nilable(Sequel::Privacy::ViewerContext)) }
|
|
619
611
|
def viewer_context
|
|
620
|
-
@viewer_context
|
|
612
|
+
@viewer_context = T.let(@viewer_context, T.nilable(Sequel::Privacy::ViewerContext))
|
|
621
613
|
end
|
|
622
614
|
|
|
623
615
|
sig { params(vc: T.nilable(Sequel::Privacy::ViewerContext)).returns(T.nilable(Sequel::Privacy::ViewerContext)) }
|
|
624
616
|
def viewer_context=(vc)
|
|
625
|
-
@viewer_context = vc
|
|
617
|
+
@viewer_context = T.let(vc, T.nilable(Sequel::Privacy::ViewerContext))
|
|
626
618
|
end
|
|
627
619
|
|
|
628
620
|
# Attach a viewer context to this model instance
|
|
629
621
|
sig { params(vc: Sequel::Privacy::ViewerContext).returns(T.self_type) }
|
|
630
622
|
def for_vc(vc)
|
|
631
|
-
@viewer_context = vc
|
|
623
|
+
@viewer_context = T.let(vc, T.nilable(Sequel::Privacy::ViewerContext))
|
|
632
624
|
self
|
|
633
625
|
end
|
|
634
626
|
|
|
@@ -646,45 +638,31 @@ module Sequel
|
|
|
646
638
|
).returns(T::Boolean)
|
|
647
639
|
end
|
|
648
640
|
def allow?(vc, action, direct_object = nil)
|
|
649
|
-
policies =
|
|
641
|
+
policies = _privacy_class.privacy_policies[action]
|
|
650
642
|
unless policies
|
|
651
643
|
Sequel::Privacy.logger&.error("No policies defined for :#{action} on #{self.class}")
|
|
652
644
|
return false
|
|
653
645
|
end
|
|
654
646
|
|
|
655
|
-
|
|
656
|
-
# This signals to association wrappers that they should return raw data
|
|
657
|
-
# without filtering, allowing policies to check things like "is actor a
|
|
658
|
-
# member of this list?" by accessing list.members without recursively
|
|
659
|
-
# checking each member's :view policy.
|
|
660
|
-
saved_vc = @viewer_context
|
|
661
|
-
@viewer_context = Sequel::Privacy::InternalPolicyEvaluationVC.new
|
|
662
|
-
begin
|
|
663
|
-
Sequel::Privacy::Enforcer.enforce(policies, self, vc, direct_object)
|
|
664
|
-
ensure
|
|
665
|
-
@viewer_context = saved_vc
|
|
666
|
-
end
|
|
647
|
+
Sequel::Privacy::Enforcer.enforce(policies, self, vc, direct_object)
|
|
667
648
|
end
|
|
668
649
|
|
|
669
650
|
# Override save to check privacy policies
|
|
670
651
|
sig { params(opts: T.untyped).returns(T.nilable(T.self_type)) }
|
|
671
652
|
def save(*opts)
|
|
672
|
-
vc =
|
|
653
|
+
vc = viewer_context
|
|
673
654
|
|
|
674
655
|
if vc.is_a?(Sequel::Privacy::OmniscientVC)
|
|
675
|
-
Kernel.raise Sequel::Privacy::Unauthorized,
|
|
656
|
+
Kernel.raise Sequel::Privacy::Unauthorized, 'Cannot mutate with OmniscientVC'
|
|
676
657
|
end
|
|
677
658
|
|
|
678
659
|
if vc
|
|
679
660
|
action = new? ? :create : :edit
|
|
680
661
|
|
|
681
|
-
unless allow?(vc, action)
|
|
682
|
-
Kernel.raise Sequel::Privacy::Unauthorized, "Cannot #{action} #{self.class}"
|
|
683
|
-
end
|
|
662
|
+
Kernel.raise Sequel::Privacy::Unauthorized, "Cannot #{action} #{self.class}" unless allow?(vc, action)
|
|
684
663
|
|
|
685
|
-
# Check field-level policies on changed fields
|
|
686
664
|
changed_columns.each do |field|
|
|
687
|
-
policy =
|
|
665
|
+
policy = _privacy_class.privacy_fields[field]
|
|
688
666
|
next unless policy
|
|
689
667
|
|
|
690
668
|
unless allow?(vc, policy)
|
|
@@ -700,14 +678,12 @@ module Sequel
|
|
|
700
678
|
# Override update to check privacy policies
|
|
701
679
|
sig { params(hash: T::Hash[Symbol, T.untyped]).returns(T.self_type) }
|
|
702
680
|
def update(hash)
|
|
703
|
-
vc =
|
|
681
|
+
vc = viewer_context
|
|
704
682
|
if vc
|
|
705
|
-
unless allow?(vc, :edit)
|
|
706
|
-
Kernel.raise Sequel::Privacy::Unauthorized, "Cannot edit #{self.class}"
|
|
707
|
-
end
|
|
683
|
+
Kernel.raise Sequel::Privacy::Unauthorized, "Cannot edit #{self.class}" unless allow?(vc, :edit)
|
|
708
684
|
|
|
709
685
|
hash.each_key do |field|
|
|
710
|
-
policy =
|
|
686
|
+
policy = _privacy_class.privacy_fields[field]
|
|
711
687
|
next unless policy
|
|
712
688
|
|
|
713
689
|
unless allow?(vc, policy)
|
|
@@ -720,11 +696,21 @@ module Sequel
|
|
|
720
696
|
super
|
|
721
697
|
end
|
|
722
698
|
|
|
699
|
+
private
|
|
700
|
+
|
|
701
|
+
# Typed view of the model class for accessing methods mixed in by the
|
|
702
|
+
# privacy plugin. The cast is sound because every class that includes
|
|
703
|
+
# InstanceMethods also extends ClassMethods (via mixes_in_class_methods).
|
|
704
|
+
sig { returns(ClassMethods) }
|
|
705
|
+
def _privacy_class
|
|
706
|
+
T.cast(self.class, ClassMethods)
|
|
707
|
+
end
|
|
708
|
+
|
|
723
709
|
# Override delete to block OmniscientVC
|
|
724
710
|
sig { returns(T.self_type) }
|
|
725
711
|
def delete
|
|
726
|
-
if
|
|
727
|
-
Kernel.raise Sequel::Privacy::Unauthorized,
|
|
712
|
+
if viewer_context.is_a?(Sequel::Privacy::OmniscientVC)
|
|
713
|
+
Kernel.raise Sequel::Privacy::Unauthorized, 'Cannot delete with OmniscientVC'
|
|
728
714
|
end
|
|
729
715
|
super
|
|
730
716
|
end
|
|
@@ -751,13 +737,13 @@ module Sequel
|
|
|
751
737
|
vc = opts[:viewer_context]
|
|
752
738
|
return super unless vc
|
|
753
739
|
|
|
754
|
-
model_class = T.
|
|
740
|
+
model_class = T.cast(model, ClassMethods)
|
|
755
741
|
vc_key = model_class.privacy_vc_key
|
|
756
742
|
proc do |values|
|
|
757
743
|
old_vc = Thread.current[vc_key]
|
|
758
744
|
Thread.current[vc_key] = vc
|
|
759
745
|
begin
|
|
760
|
-
model_class.
|
|
746
|
+
model_class.(values)
|
|
761
747
|
ensure
|
|
762
748
|
Thread.current[vc_key] = old_vc
|
|
763
749
|
end
|
|
@@ -1,40 +1,50 @@
|
|
|
1
|
-
# typed:
|
|
1
|
+
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
module Sequel
|
|
5
5
|
module Privacy
|
|
6
6
|
# Actions provides the DSL methods available inside policy lambdas.
|
|
7
|
-
# When policies are evaluated, they execute in the context of this
|
|
7
|
+
# When policies are evaluated, they execute in the context of this object,
|
|
8
8
|
# giving them access to allow, deny, pass, and all methods.
|
|
9
9
|
#
|
|
10
10
|
# Example:
|
|
11
11
|
# policy :AllowAdmins, ->(actor) {
|
|
12
12
|
# allow if actor.is_role?(:admin)
|
|
13
13
|
# }
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
class ActionsClass
|
|
15
|
+
extend T::Sig
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
17
|
+
sig { returns(Symbol) }
|
|
18
|
+
def allow
|
|
19
|
+
:allow
|
|
20
|
+
end
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
sig { returns(Symbol) }
|
|
23
|
+
def deny
|
|
24
|
+
:deny
|
|
25
|
+
end
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
sig { returns(Symbol) }
|
|
28
|
+
def pass
|
|
29
|
+
:pass
|
|
30
|
+
end
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
32
|
+
# Combine multiple policies - all must allow for the result to allow.
|
|
33
|
+
# Any deny results in deny. Otherwise passes.
|
|
34
|
+
sig { params(policies: T.untyped).returns(T::Array[T.untyped]) }
|
|
35
|
+
def all(*policies)
|
|
36
|
+
policies
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Evaluate a policy lambda in the DSL context. Wraps instance_exec so
|
|
40
|
+
# callers don't have to fight Sorbet's strict block-shape signatures
|
|
41
|
+
# for arbitrary-arity policies.
|
|
42
|
+
sig { params(args: T.untyped, blk: Proc).returns(T.untyped) }
|
|
43
|
+
def evaluate(*args, &blk)
|
|
44
|
+
T.unsafe(self).instance_exec(*args, &blk)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
Actions = T.let(ActionsClass.new, ActionsClass)
|
|
39
49
|
end
|
|
40
50
|
end
|
|
@@ -8,6 +8,13 @@ module Sequel
|
|
|
8
8
|
module Enforcer
|
|
9
9
|
extend T::Sig
|
|
10
10
|
|
|
11
|
+
# Thread-local flag set while a policy chain is being evaluated.
|
|
12
|
+
# Implicit :view enforcement (Model.call, field readers, association
|
|
13
|
+
# readers) checks this flag and returns raw data when set, so policies
|
|
14
|
+
# can traverse protected fields and associations without recursive
|
|
15
|
+
# filtering. Explicit `allow?` calls always run regardless.
|
|
16
|
+
EVAL_KEY = :sequel_privacy_in_policy_eval
|
|
17
|
+
|
|
11
18
|
class << self
|
|
12
19
|
extend T::Sig
|
|
13
20
|
|
|
@@ -18,6 +25,11 @@ module Sequel
|
|
|
18
25
|
end
|
|
19
26
|
end
|
|
20
27
|
|
|
28
|
+
sig { returns(T::Boolean) }
|
|
29
|
+
def self.in_policy_eval?
|
|
30
|
+
Thread.current[EVAL_KEY] == true
|
|
31
|
+
end
|
|
32
|
+
|
|
21
33
|
# Main entry point for policy evaluation.
|
|
22
34
|
#
|
|
23
35
|
# @param policies [Array<Policy, Proc>] The policy chain to evaluate
|
|
@@ -34,39 +46,44 @@ module Sequel
|
|
|
34
46
|
).returns(T::Boolean)
|
|
35
47
|
end
|
|
36
48
|
def self.enforce(policies, subject, viewer_context, direct_object = nil)
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
49
|
+
saved = Thread.current[EVAL_KEY]
|
|
50
|
+
Thread.current[EVAL_KEY] = true
|
|
51
|
+
|
|
52
|
+
begin
|
|
53
|
+
# All-powerful and omniscient contexts bypass all checks
|
|
54
|
+
if viewer_context.is_a?(AllPowerfulVC)
|
|
55
|
+
logger&.warn('BYPASS: All-powerful viewer context bypasses all privacy rules.')
|
|
56
|
+
return true
|
|
57
|
+
end
|
|
42
58
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
59
|
+
if viewer_context.is_a?(OmniscientVC)
|
|
60
|
+
logger&.debug { "BYPASS: Omniscient viewer context (#{viewer_context.reason})" }
|
|
61
|
+
return true
|
|
62
|
+
end
|
|
47
63
|
|
|
48
|
-
|
|
64
|
+
actor = viewer_context.is_a?(ActorVC) ? viewer_context.actor : nil
|
|
49
65
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
end
|
|
66
|
+
if policies.empty?
|
|
67
|
+
logger&.error { "No policies for #{subject.class}[#{subject_id(subject)}]. Denying by default." }
|
|
68
|
+
policies = [BuiltInPolicies::AlwaysDeny]
|
|
69
|
+
end
|
|
55
70
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
71
|
+
# Ensure policy chain ends with AlwaysDeny (fail-secure)
|
|
72
|
+
unless policies.last == BuiltInPolicies::AlwaysDeny
|
|
73
|
+
logger&.warn { 'Policy chain should end with AlwaysDeny. Appending it.' }
|
|
74
|
+
policies = policies.dup << BuiltInPolicies::AlwaysDeny
|
|
75
|
+
end
|
|
61
76
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
end
|
|
77
|
+
policies.each do |uncasted_policy|
|
|
78
|
+
result = policy_result(uncasted_policy, subject, actor, viewer_context, direct_object)
|
|
79
|
+
return true if result == :allow
|
|
80
|
+
return false if result == :deny
|
|
81
|
+
end
|
|
68
82
|
|
|
69
|
-
|
|
83
|
+
false
|
|
84
|
+
ensure
|
|
85
|
+
Thread.current[EVAL_KEY] = saved
|
|
86
|
+
end
|
|
70
87
|
end
|
|
71
88
|
|
|
72
89
|
# Compute cache key based on policy arity
|
|
@@ -201,13 +218,13 @@ module Sequel
|
|
|
201
218
|
|
|
202
219
|
case policy.arity
|
|
203
220
|
when 0
|
|
204
|
-
Actions.
|
|
221
|
+
Actions.evaluate(&policy)
|
|
205
222
|
when 1
|
|
206
|
-
Actions.
|
|
223
|
+
Actions.evaluate(subject, &policy)
|
|
207
224
|
when 2
|
|
208
|
-
Actions.
|
|
225
|
+
Actions.evaluate(subject, T.must(actor), &policy)
|
|
209
226
|
else
|
|
210
|
-
Actions.
|
|
227
|
+
Actions.evaluate(subject, T.must(actor), direct_object, &policy)
|
|
211
228
|
end
|
|
212
229
|
end
|
|
213
230
|
|
|
@@ -21,11 +21,12 @@ module Sequel
|
|
|
21
21
|
sig { returns(T.nilable(String)) }
|
|
22
22
|
attr_reader :comment
|
|
23
23
|
|
|
24
|
-
# Factory method for creating policies
|
|
24
|
+
# Factory method for creating policies. Accepts procs of any arity
|
|
25
|
+
# (0–3 args) returning :allow, :deny, :pass, or an Array of policies.
|
|
25
26
|
sig do
|
|
26
27
|
params(
|
|
27
28
|
policy_name: Symbol,
|
|
28
|
-
lam:
|
|
29
|
+
lam: Proc,
|
|
29
30
|
comment: T.nilable(String),
|
|
30
31
|
cacheable: T::Boolean,
|
|
31
32
|
single_match: T::Boolean
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# typed:
|
|
1
|
+
# typed: true
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
module Sequel
|
|
@@ -17,6 +17,13 @@ module Sequel
|
|
|
17
17
|
# }, 'Allow admin users', cacheable: true
|
|
18
18
|
# end
|
|
19
19
|
module PolicyDSL
|
|
20
|
+
extend T::Sig
|
|
21
|
+
extend T::Helpers
|
|
22
|
+
|
|
23
|
+
# PolicyDSL is meant to be `extend`ed onto another Module, so `self`
|
|
24
|
+
# at runtime is always a Module that responds to `const_set`.
|
|
25
|
+
requires_ancestor { Module }
|
|
26
|
+
|
|
20
27
|
# Define a new policy constant on the extending module.
|
|
21
28
|
#
|
|
22
29
|
# @param name [Symbol] The policy name (will become a constant)
|
|
@@ -24,6 +31,15 @@ module Sequel
|
|
|
24
31
|
# @param comment [String, nil] Human-readable description
|
|
25
32
|
# @param cacheable [Boolean] Whether results can be cached (default: true)
|
|
26
33
|
# @param single_match [Boolean] Whether only one subject/actor can match (default: false)
|
|
34
|
+
sig do
|
|
35
|
+
params(
|
|
36
|
+
name: Symbol,
|
|
37
|
+
lam: Proc,
|
|
38
|
+
comment: T.nilable(String),
|
|
39
|
+
cacheable: T::Boolean,
|
|
40
|
+
single_match: T::Boolean
|
|
41
|
+
).void
|
|
42
|
+
end
|
|
27
43
|
def policy(name, lam, comment = nil, cacheable: true, single_match: false)
|
|
28
44
|
p = Policy.new(&lam).setup(
|
|
29
45
|
policy_name: name,
|
|
@@ -105,22 +105,6 @@ module Sequel
|
|
|
105
105
|
end
|
|
106
106
|
end
|
|
107
107
|
|
|
108
|
-
# Internal policy evaluation viewer context.
|
|
109
|
-
# Used internally during policy evaluation to allow raw association access
|
|
110
|
-
# without triggering recursive privacy checks. For example, when checking
|
|
111
|
-
# "is actor a member of this list?", we need to access list.members without
|
|
112
|
-
# filtering those members by their own :view policies.
|
|
113
|
-
#
|
|
114
|
-
# This class is internal to the privacy plugin and should not be used directly.
|
|
115
|
-
class InternalPolicyEvaluationVC < ViewerContext
|
|
116
|
-
extend T::Sig
|
|
117
|
-
|
|
118
|
-
sig { void }
|
|
119
|
-
def initialize
|
|
120
|
-
super()
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
|
|
124
108
|
# Type alias for viewer contexts
|
|
125
109
|
TViewerContext = T.type_alias { ViewerContext }
|
|
126
110
|
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.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Austin Bales
|
|
@@ -116,7 +116,6 @@ files:
|
|
|
116
116
|
- lib/sequel/privacy/policy_dsl.rb
|
|
117
117
|
- lib/sequel/privacy/version.rb
|
|
118
118
|
- lib/sequel/privacy/viewer_context.rb
|
|
119
|
-
- rbi/sequel_privacy.rbi
|
|
120
119
|
homepage: https://github.com/arbales/sequel-privacy
|
|
121
120
|
licenses:
|
|
122
121
|
- MIT
|
data/rbi/sequel_privacy.rbi
DELETED
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
# typed: true
|
|
2
|
-
|
|
3
|
-
module Sequel
|
|
4
|
-
module Privacy
|
|
5
|
-
# Actions is a Struct instance used as the binding context for policy
|
|
6
|
-
# evaluation via instance_exec. Defined in actions.rb (typed: ignore).
|
|
7
|
-
class Actions
|
|
8
|
-
extend T::Sig
|
|
9
|
-
|
|
10
|
-
sig { returns(Symbol) }
|
|
11
|
-
def allow; end
|
|
12
|
-
|
|
13
|
-
sig { returns(Symbol) }
|
|
14
|
-
def deny; end
|
|
15
|
-
|
|
16
|
-
sig { returns(Symbol) }
|
|
17
|
-
def pass; end
|
|
18
|
-
|
|
19
|
-
sig { params(policies: T.untyped).returns(T::Array[T.untyped]) }
|
|
20
|
-
def all(*policies); end
|
|
21
|
-
|
|
22
|
-
sig {
|
|
23
|
-
params(
|
|
24
|
-
args: T.untyped,
|
|
25
|
-
block: Policy
|
|
26
|
-
).returns(T.untyped)
|
|
27
|
-
}
|
|
28
|
-
def self.instance_exec(*args, &block); end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
class PrivacyDSL
|
|
32
|
-
extend T::Sig
|
|
33
|
-
|
|
34
|
-
sig { params(action: Symbol, policies: T.untyped).void }
|
|
35
|
-
def can(action, *policies); end
|
|
36
|
-
|
|
37
|
-
sig { params(field_name: Symbol, policies: T.untyped).void }
|
|
38
|
-
def field(field_name, *policies); end
|
|
39
|
-
|
|
40
|
-
sig { params(association_name: Symbol, blk: T.proc.void).void }
|
|
41
|
-
def association(association_name, &blk); end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
module Plugins
|
|
46
|
-
module Privacy
|
|
47
|
-
module ClassMethods
|
|
48
|
-
# The privacy block is evaluated in the context of PrivacyDSL
|
|
49
|
-
sig { params(blk: T.proc.bind(Sequel::Privacy::PrivacyDSL).void).void }
|
|
50
|
-
def privacy(&blk); end
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
module InstanceMethods
|
|
54
|
-
# Declare the @viewer_context instance variable for the mixin
|
|
55
|
-
sig { returns(T.nilable(Sequel::Privacy::ViewerContext)) }
|
|
56
|
-
attr_accessor :viewer_context
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
module DatasetMethods
|
|
60
|
-
# model is inherited from Sequel::Dataset but not visible to Sorbet
|
|
61
|
-
sig { returns(T.class_of(Sequel::Model)) }
|
|
62
|
-
def model; end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|