sequel-privacy 0.5.1 → 0.5.3

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: af7fc02c1fb57d47ba358babdebb3289c2d8eb6ec43d2fce25a2c3477a3f2ccc
4
- data.tar.gz: a18b09c73b5b03540d2a74f985d32c99b6b2a090a714346f2c87ef5a2997252c
3
+ metadata.gz: 2f2538fcf5aee16c7b624701e81d936df211b20fa007cf9fa1fffc893bda0105
4
+ data.tar.gz: e1df8a2b58c2e6851447e2874c837a865641be73ed0adc25c255bcb9076d4087
5
5
  SHA512:
6
- metadata.gz: 9f46a3f0f253061be75dcd2fb80cf797c1be229d5fd83b1c9f1c86860621dae4536ea60b06df4e38d2d6b1e66f0e90533f0d3a75dddc48be388898ac1d8517d4
7
- data.tar.gz: f4aee413ab143a08366b223e2c34d62165c69f1e63871eee400d6f82af3c096266e0646f3b4d3d4649a761c913e2c07cef8f852586f0c535d0af3daf1d02f9a6
6
+ metadata.gz: 68996d09fde85691254e51d6edb40f4a32007c07a232b768a705d29b7b4ea37ebe95a2ac092c18f201a7516b53108d4a8ff8356a0f860c288a18903648b5c1b6
7
+ data.tar.gz: d1b9364e64145dbe2993c18bb8db458826b2480e9e827f589ed6cecefdbb1e2c82e0c2fdd72a3542946fb4bea70e2d58c37f81889067b3b5e7c5ce7912032477
data/README.md CHANGED
@@ -221,56 +221,34 @@ system, so these VCs give you an escape hatch for things like scripts while also
221
221
  You could also create lint rules that prevent the casual creation of these viewer contexts.
222
222
 
223
223
  ```ruby
224
+ # You can use an omniscient viewer context to load the user from a session
225
+ # or however you store them. Discard this viewer context when you're done with it.
226
+ def current_user
227
+ return @current_user if @current_user
228
+ login_vc = Sequel::Privacy::ViewerContext.omniscient(:login)
229
+ user = User.for_vc(login_vc)[session_user_id]
230
+ return nil unless user
231
+
232
+ # Attach an ActorVC to the loaded user so that future calls to its fields and
233
+ # associations respect privacy .
234
+ @current_user ||= user.for_vc(Sequel::Privacy::ViewerContext.for_actor(user))
235
+ end
236
+
237
+ def current_vc
238
+ current_user&.vc || Sequel::Privacy::ViewerContext.anonymous()
239
+ end
240
+
224
241
  # Standard viewer (most common)
225
- current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
226
242
  users_groups = Group.for_vc(current_vc).where(creator: current_user).all
227
243
 
228
- # API-specific (can be distinguished in policies)
229
- vc = Sequel::Privacy::ViewerContext.for_api_actor(current_user)
230
-
231
244
  # Anonymous viewer (logged-out users)
232
245
  logged_out_vc = Sequel::Privacy::ViewerContext.anonymous
233
246
  posts = Post.for_vc(logged_out_vc).where(published: true).all
234
247
 
235
- # Omniscient VCs can read any object in the system, but are incapable of writes.
236
- # Dispose of these ViewerContexts quickly.
237
- current_user = Sequel::Privacy::ViewerContext.omniscient(:login).then {|vc| User.for_vc(vc)[authenticated_user_id] }
238
- current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
239
-
240
248
  # All-powerful ViewerContexts dangerously bypass all read and write checks.
241
249
  admin_vc = Sequel::Privacy::ViewerContext.all_powerful(:admin_migration)
242
250
  ```
243
251
 
244
- ### A Note Login & Authenticated Users
245
-
246
- If your User or equivalent model is privacy-aware *and* is protected by
247
- policies that would complicating fetching (or login), then you will have
248
- trouble creating a `current_user` for an `ActorVC`.
249
-
250
- In both cases you can use an `OmniscientVC` to make your initial User query.
251
-
252
- ```ruby
253
- before do
254
- if session[:user_id]
255
- current_user = Sequel::Privacy::ViewerContext.omniscient(:session).then {|vc| User.for_vc(vc)[session[:user_id]] }
256
- current_vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
257
- else
258
- current_user = nil
259
- current_vc = Sequel::Privacy::ViewerContext.anonymous
260
- end
261
- end
262
-
263
- post '/auth/password' do
264
- user = Sequel::Privacy::ViewerContext.omniscient(:login).then {|vc| User.for_vc(vc).first(email: params[:email]) }
265
-
266
- pass unless user
267
- pass unless user.password == params[:password]
268
-
269
- session[:user_id] = user.id
270
- redirect '/'
271
- end
272
- ```
273
-
274
252
  ## Mutation Enforcement
275
253
 
276
254
  When a viewer context is attached, mutations are automatically checked:
@@ -36,7 +36,6 @@ module Sequel
36
36
  module Privacy
37
37
  extend T::Sig
38
38
 
39
- # Called once when plugin first loads on a model
40
39
  sig { params(model: T.class_of(Sequel::Model), _opts: T::Hash[Symbol, T.untyped]).void }
41
40
  def self.apply(model, _opts = {})
42
41
  model.instance_variable_set(:@privacy_policies, {})
@@ -46,13 +45,9 @@ module Sequel
46
45
  model.instance_variable_set(:@allow_unsafe_access, false)
47
46
  end
48
47
 
49
- # Called every time plugin loads (for per-model configuration)
50
48
  sig { params(model: T.class_of(Sequel::Model), opts: T::Hash[Symbol, T.untyped]).void }
51
- def self.configure(model, opts = {})
52
- # Currently no per-model configuration needed
53
- end
49
+ def self.configure(model, opts = {}); end
54
50
 
55
- # DSL class for defining association-level privacy policies
56
51
  class AssociationPrivacyDSL
57
52
  extend T::Sig
58
53
 
@@ -67,7 +62,6 @@ module Sequel
67
62
  @pending_policies = T.let({}, T::Hash[Symbol, T::Array[T.untyped]])
68
63
  end
69
64
 
70
- # Define policies for association actions (:add, :remove, :remove_all)
71
65
  sig { params(action: Symbol, policies: T.untyped).void }
72
66
  def can(action, *policies)
73
67
  unless %i[add remove remove_all].include?(action)
@@ -80,17 +74,15 @@ module Sequel
80
74
  T.must(@pending_policies[action]).concat(resolved)
81
75
  end
82
76
 
83
- # Called after the association block is evaluated to register all policies at once
84
77
  sig { void }
85
78
  def finalize_association!
86
79
  @pending_policies.each do |action, policies|
87
- @model_class.register_association_policies(@assoc_name, action, policies, defer_setup: true)
80
+ @model_class.register_association_policies(@assoc_name, action, policies)
88
81
  end
89
82
  @model_class.setup_association_privacy(@assoc_name)
90
83
  end
91
84
  end
92
85
 
93
- # DSL class for defining privacy policies in a block
94
86
  class PrivacyDSL
95
87
  extend T::Sig
96
88
 
@@ -99,14 +91,12 @@ module Sequel
99
91
  @model_class = model_class
100
92
  end
101
93
 
102
- # Define policies for an action
103
94
  sig { params(action: Symbol, policies: T.untyped).void }
104
95
  def can(action, *policies)
105
96
  resolved = resolve_policies(policies)
106
97
  @model_class.register_policies(action, resolved)
107
98
  end
108
99
 
109
- # Define a protected field with its policies
110
100
  sig { params(name: Symbol, policies: T.untyped).void }
111
101
  def field(name, *policies)
112
102
  resolved = resolve_policies(policies)
@@ -115,14 +105,6 @@ module Sequel
115
105
  @model_class.register_protected_field(name, policy_name)
116
106
  end
117
107
 
118
- # Define policies for an association
119
- #
120
- # Example:
121
- # association :members do
122
- # can :add, AllowGroupAdmin, AllowSelfJoin
123
- # can :remove, AllowGroupAdmin, AllowSelfRemove
124
- # can :remove_all, AllowGroupAdmin
125
- # end
126
108
  sig { params(name: Symbol, block: T.proc.bind(AssociationPrivacyDSL).void).void }
127
109
  def association(name, &block)
128
110
  resolver = ->(policies) { resolve_policies(policies) }
@@ -131,7 +113,6 @@ module Sequel
131
113
  dsl.finalize_association!
132
114
  end
133
115
 
134
- # Finalize privacy settings (no more changes allowed)
135
116
  sig { void }
136
117
  def finalize!
137
118
  @model_class.finalize_privacy!
@@ -158,7 +139,6 @@ module Sequel
158
139
 
159
140
  requires_ancestor { T.class_of(Sequel::Model) }
160
141
 
161
- # Register inherited instance variables for proper subclass handling
162
142
  Sequel::Plugins.inherited_instance_variables(
163
143
  self,
164
144
  :@privacy_policies => :dup,
@@ -168,12 +148,8 @@ module Sequel
168
148
  :@allow_unsafe_access => nil
169
149
  )
170
150
 
171
- # ─────────────────────────────────────────────────────────────────────
172
- # Strict Mode Enforcement
173
- # ─────────────────────────────────────────────────────────────────────
174
-
175
- # Allow this model to be accessed without a ViewerContext.
176
- # Use during migration to gradually enable strict mode.
151
+ # Allows the model to be accessed without a ViewerContext,
152
+ # useful when you're migrating an existing codebase or adopting gradually.
177
153
  sig { void }
178
154
  def allow_unsafe_access!
179
155
  @allow_unsafe_access = T.let(true, T.nilable(T::Boolean))
@@ -185,20 +161,15 @@ module Sequel
185
161
  @allow_unsafe_access == true
186
162
  end
187
163
 
188
- # Thread-local key for storing the current ViewerContext during row processing
164
+ # Per-class thread-local key carrying the current VC during row
165
+ # materialization.
189
166
  sig { returns(Symbol) }
190
167
  def privacy_vc_key
191
168
  :"#{self}_privacy_vc"
192
169
  end
193
170
 
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.
171
+ # The primary integration point; every Sequel::Model materialization
172
+ # flows through here.
202
173
  sig { params(values: T.untyped).returns(T.nilable(Sequel::Model)) }
203
174
  def call(values)
204
175
  vc = Thread.current[privacy_vc_key]
@@ -211,10 +182,6 @@ module Sequel
211
182
  super
212
183
  end
213
184
 
214
- # ─────────────────────────────────────────────────────────────────────
215
- # Policy Definition DSL
216
- # ─────────────────────────────────────────────────────────────────────
217
-
218
185
  sig { returns(T::Hash[Symbol, T::Array[T.untyped]]) }
219
186
  def privacy_policies
220
187
  @privacy_policies ||= T.let({}, T.nilable(T::Hash[Symbol, T::Array[T.untyped]]))
@@ -225,7 +192,6 @@ module Sequel
225
192
  @privacy_fields ||= T.let({}, T.nilable(T::Hash[Symbol, Symbol]))
226
193
  end
227
194
 
228
- # Returns association policies: { assoc_name => { action => [policies] } }
229
195
  sig { returns(T::Hash[Symbol, T::Hash[Symbol, T::Array[T.untyped]]]) }
230
196
  def privacy_association_policies
231
197
  @privacy_association_policies ||= T.let({}, T.nilable(T::Hash[Symbol, T::Hash[Symbol, T::Array[T.untyped]]]))
@@ -236,11 +202,9 @@ module Sequel
236
202
  @privacy_finalized == true
237
203
  end
238
204
 
239
- # DSL entry point for defining privacy policies
240
- #
241
- # @yield Block evaluated in context of PrivacyDSL
205
+ # Entry point for the privacy DSL. The block is evaluated in the
206
+ # context of a `PrivacyDSL` instance:
242
207
  #
243
- # Example:
244
208
  # privacy do
245
209
  # can :view, P::AllowMembers
246
210
  # can :edit, P::AllowSelf, P::AllowAdmins
@@ -256,7 +220,6 @@ module Sequel
256
220
  dsl.instance_eval(&block)
257
221
  end
258
222
 
259
- # Register policies for an action (called by PrivacyDSL)
260
223
  sig { params(action: Symbol, policies: T::Array[T.untyped]).void }
261
224
  def register_policies(action, policies)
262
225
  if privacy_finalized?
@@ -267,7 +230,6 @@ module Sequel
267
230
  T.must(privacy_policies[action]).concat(policies)
268
231
  end
269
232
 
270
- # Register a protected field (called by PrivacyDSL)
271
233
  sig { params(field: Symbol, policy_name: Symbol).void }
272
234
  def register_protected_field(field, policy_name)
273
235
  if privacy_finalized?
@@ -276,13 +238,9 @@ module Sequel
276
238
 
277
239
  privacy_fields[field] = policy_name
278
240
 
279
- # Store original method
280
241
  original_method = instance_method(field)
281
242
 
282
- # Override the field getter
283
243
  define_method(field) do
284
- # During nested policy evaluation, return raw value without
285
- # checking the field's view policy.
286
244
  return original_method.bind(self).() if Sequel::Privacy::Enforcer.in_policy_eval?
287
245
 
288
246
  vc = instance_variable_get(:@viewer_context)
@@ -301,23 +259,20 @@ module Sequel
301
259
  end
302
260
  end
303
261
 
304
- # Register association policies (called by AssociationPrivacyDSL)
305
- # @param defer_setup [Boolean] If true, don't set up wrappers yet (caller will call setup_association_privacy)
306
- sig { params(assoc_name: Symbol, action: Symbol, policies: T::Array[T.untyped], defer_setup: T::Boolean).void }
307
- def register_association_policies(assoc_name, action, policies, defer_setup: false)
262
+ # The caller is responsible for invoking `setup_association_privacy`
263
+ # once all actions have been registered.
264
+ sig { params(assoc_name: Symbol, action: Symbol, policies: T::Array[T.untyped]).void }
265
+ def register_association_policies(assoc_name, action, policies)
308
266
  Kernel.raise "Privacy policies have been finalized for #{self}" if privacy_finalized?
309
267
 
310
268
  privacy_association_policies[assoc_name] ||= {}
311
269
  assoc_hash = T.must(privacy_association_policies[assoc_name])
312
270
  assoc_hash[action] ||= []
313
271
  T.must(assoc_hash[action]).concat(policies)
314
-
315
- # Set up the association method overrides if the association exists (unless deferred)
316
- setup_association_privacy(assoc_name) if !defer_setup && association_reflection(assoc_name)
317
272
  end
318
273
 
319
- # Set up privacy-wrapped add_*/remove_*/remove_all_* methods for an association
320
- # This is called after all policies for an association have been registered
274
+ # Wraps add_*/remove_*/remove_all_* methods on an association
275
+ # with privacy checks. Idempotent.
321
276
  sig { params(assoc_name: Symbol).void }
322
277
  def setup_association_privacy(assoc_name)
323
278
  assoc_policies = privacy_association_policies[assoc_name]
@@ -326,47 +281,40 @@ module Sequel
326
281
  reflection = association_reflection(assoc_name)
327
282
  return unless reflection
328
283
 
329
- # Track which associations have been wrapped to avoid double-wrapping
330
284
  @_wrapped_associations ||= T.let({}, T.nilable(T::Hash[Symbol, T::Boolean]))
331
285
  return if @_wrapped_associations[assoc_name]
332
286
 
333
287
  @_wrapped_associations[assoc_name] = true
334
288
 
335
- # Determine the singular name for method naming
336
- # For many_to_many :members, methods are add_member, remove_member
337
- # For one_to_many :memberships, methods are add_membership, remove_membership
289
+ # Sequel derives mutator names by stripping a trailing 's' from
290
+ # the association name: many_to_many :members add_member,
291
+ # one_to_many :memberships add_membership.
292
+ #
293
+ # TODO: I'm not sure if this will break sometimes.
338
294
  singular_name = reflection[:name].to_s.chomp('s').to_sym
339
295
 
340
- # Wrap add_* method if :add policy exists
341
296
  add_policies = assoc_policies[:add]
342
297
  if add_policies && method_defined?(:"add_#{singular_name}")
343
298
  _wrap_association_add(assoc_name, singular_name, add_policies)
344
299
  end
345
300
 
346
- # Wrap remove_* method if :remove policy exists
347
301
  remove_policies = assoc_policies[:remove]
348
302
  if remove_policies && method_defined?(:"remove_#{singular_name}")
349
303
  _wrap_association_remove(assoc_name, singular_name, remove_policies)
350
304
  end
351
305
 
352
- # Wrap remove_all_* method if :remove_all policy exists
353
306
  remove_all_policies = assoc_policies[:remove_all]
354
307
  return unless remove_all_policies && method_defined?(:"remove_all_#{reflection[:name]}")
355
308
 
356
309
  _wrap_association_remove_all(assoc_name, reflection[:name], remove_all_policies)
357
310
  end
358
311
 
359
- # Finalize privacy settings (no more changes allowed)
360
- # TODO: Explore automatic finalization on first query
312
+ # TODO: explore automatic finalization on first query.
361
313
  sig { void }
362
314
  def finalize_privacy!
363
315
  @privacy_finalized = T.let(true, T.nilable(T::Boolean))
364
316
  end
365
317
 
366
- # ─────────────────────────────────────────────────────────────────────
367
- # Deprecated Methods (for backwards compatibility)
368
- # ─────────────────────────────────────────────────────────────────────
369
-
370
318
  # @deprecated Use `privacy do; can :action, ...; end` instead
371
319
  sig { params(action: Symbol, policy_chain: T.untyped).void }
372
320
  def policies(action, *policy_chain)
@@ -379,7 +327,6 @@ module Sequel
379
327
  def protect_field(field, policy: nil)
380
328
  Kernel.warn "DEPRECATED: #{self}.protect_field is deprecated. Use `privacy do; field :#{field}, ...; end` instead"
381
329
  policy_name = policy || :"view_#{field}"
382
- # Need to also register the policy if not already defined
383
330
  register_protected_field(field, policy_name)
384
331
  end
385
332
 
@@ -389,19 +336,11 @@ module Sequel
389
336
  dataset.for_vc(vc)
390
337
  end
391
338
 
392
- # ─────────────────────────────────────────────────────────────────────
393
- # Association Privacy (hooks into association creation)
394
- # ─────────────────────────────────────────────────────────────────────
395
-
396
- # Override Sequel's associate method to wrap associations with privacy checks
397
339
  sig { params(type: Symbol, name: Symbol, opts: T.untyped, block: T.untyped).returns(T.untyped) }
398
340
  def associate(type, name, opts = {}, &block)
399
341
  opts = _inject_privacy_eager_block(opts)
400
-
401
- # Call original to create the association
402
342
  result = super
403
343
 
404
- # Wrap the association method with privacy checks
405
344
  case type
406
345
  when :many_to_one, :one_to_one
407
346
  _override_singular_association(name)
@@ -409,18 +348,15 @@ module Sequel
409
348
  when :one_to_many, :many_to_many
410
349
  _override_plural_association(name)
411
350
  _override_association_dataset(name)
412
- # Check if there are already privacy policies defined for this association
413
351
  setup_association_privacy(name) if privacy_association_policies[name]
414
352
  end
415
353
 
416
354
  result
417
355
  end
418
356
 
419
- # Wrap the association's eager-load dataset with for_vc() so the
420
- # child rows are materialized with the current viewer context.
421
- # The VC is propagated via a thread-local set by DatasetMethods#all
422
- # so that it's only applied during eager loading, not during the
423
- # lazy association reader path (which has its own handling).
357
+ # Inject an :eager_block that wraps the eager-load dataset with
358
+ # `for_vc` when a VC is propagated via EAGER_VC_KEY (see
359
+ # DatasetMethods#post_load). Preserves any user-supplied block.
424
360
  sig { params(opts: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
425
361
  def _inject_privacy_eager_block(opts)
426
362
  original = opts[:eager_block]
@@ -465,15 +401,13 @@ module Sequel
465
401
  def _override_singular_association(name)
466
402
  original = instance_method(name)
467
403
  assoc_reflection = association_reflection(name)
404
+ # Resolve lazily to handle forward references between models.
468
405
  assoc_class = T.let(nil, T.nilable(T.class_of(Sequel::Model)))
469
406
 
470
407
  define_method(name) do
471
408
  vc = instance_variable_get(:@viewer_context)
472
-
473
- # Determine associated class (lazily, to handle forward references)
474
409
  assoc_class ||= assoc_reflection.associated_class
475
410
 
476
- # Load association with VC context set if available
477
411
  obj = if vc && assoc_class.respond_to?(:privacy_vc_key)
478
412
  vc_key = assoc_class.privacy_vc_key
479
413
  old_vc = Thread.current[vc_key]
@@ -510,11 +444,8 @@ module Sequel
510
444
 
511
445
  define_method(name) do
512
446
  vc = instance_variable_get(:@viewer_context)
513
-
514
- # Determine associated class (lazily, to handle forward references)
515
447
  assoc_class ||= assoc_reflection.associated_class
516
448
 
517
- # Load association with VC context set if available
518
449
  objs = if vc && assoc_class.respond_to?(:privacy_vc_key)
519
450
  vc_key = assoc_class.privacy_vc_key
520
451
  old_vc = Thread.current[vc_key]
@@ -564,7 +495,6 @@ module Sequel
564
495
  "Cannot #{method_name} with OmniscientVC"
565
496
  end
566
497
 
567
- # Check policy with 3-arity: (subject=self, actor, direct_object=obj)
568
498
  allowed = Sequel::Privacy::Enforcer.enforce(policies, self, vc, obj)
569
499
 
570
500
  unless allowed
@@ -596,7 +526,6 @@ module Sequel
596
526
  "Cannot #{method_name} with OmniscientVC"
597
527
  end
598
528
 
599
- # Check policy with 3-arity: (subject=self, actor, direct_object=obj)
600
529
  allowed = Sequel::Privacy::Enforcer.enforce(policies, self, vc, obj)
601
530
 
602
531
  unless allowed
@@ -628,7 +557,6 @@ module Sequel
628
557
  "Cannot #{method_name} with OmniscientVC"
629
558
  end
630
559
 
631
- # Check policy with 2-arity: (subject=self, actor) - no direct object for remove_all
632
560
  allowed = Sequel::Privacy::Enforcer.enforce(policies, self, vc)
633
561
 
634
562
  unless allowed
@@ -658,19 +586,12 @@ module Sequel
658
586
  @viewer_context = T.let(vc, T.nilable(Sequel::Privacy::ViewerContext))
659
587
  end
660
588
 
661
- # Attach a viewer context to this model instance
662
589
  sig { params(vc: Sequel::Privacy::ViewerContext).returns(T.self_type) }
663
590
  def for_vc(vc)
664
591
  @viewer_context = T.let(vc, T.nilable(Sequel::Privacy::ViewerContext))
665
592
  self
666
593
  end
667
594
 
668
- # Check if the viewer is allowed to perform an action.
669
- #
670
- # @param vc [ViewerContext] The viewer context
671
- # @param action [Symbol] The action to check (:view, :edit, :create, etc.)
672
- # @param direct_object [Sequel::Model, nil] Optional additional context
673
- # @return [Boolean]
674
595
  sig do
675
596
  params(
676
597
  vc: Sequel::Privacy::ViewerContext,
@@ -688,7 +609,6 @@ module Sequel
688
609
  Sequel::Privacy::Enforcer.enforce(policies, self, vc, direct_object)
689
610
  end
690
611
 
691
- # Override save to check privacy policies
692
612
  sig { params(opts: T.untyped).returns(T.nilable(T.self_type)) }
693
613
  def save(*opts)
694
614
  vc = viewer_context
@@ -716,7 +636,6 @@ module Sequel
716
636
  super
717
637
  end
718
638
 
719
- # Override update to check privacy policies
720
639
  sig { params(hash: T::Hash[Symbol, T.untyped]).returns(T.self_type) }
721
640
  def update(hash)
722
641
  vc = viewer_context
@@ -739,15 +658,13 @@ module Sequel
739
658
 
740
659
  private
741
660
 
742
- # Typed view of the model class for accessing methods mixed in by the
743
- # privacy plugin. The cast is sound because every class that includes
744
- # InstanceMethods also extends ClassMethods (via mixes_in_class_methods).
661
+ # Every class that includes InstanceMethods also extends ClassMethods
662
+ # via `mixes_in_class_methods`, so this should always work.
745
663
  sig { returns(ClassMethods) }
746
664
  def _privacy_class
747
665
  T.cast(self.class, ClassMethods)
748
666
  end
749
667
 
750
- # Override delete to block OmniscientVC
751
668
  sig { returns(T.self_type) }
752
669
  def delete
753
670
  if viewer_context.is_a?(Sequel::Privacy::OmniscientVC)
@@ -769,23 +686,17 @@ module Sequel
769
686
  # datasets via the :eager_block injected in ClassMethods#associate.
770
687
  EAGER_VC_KEY = :sequel_privacy_eager_vc
771
688
 
772
- # Attach viewer context to dataset for privacy enforcement on materialization
773
689
  sig { params(vc: Sequel::Privacy::ViewerContext).returns(Sequel::Dataset) }
774
690
  def for_vc(vc)
775
691
  clone(viewer_context: vc)
776
692
  end
777
693
 
778
- # Override row_proc to wrap Model.call with the full per-row
779
- # privacy pipeline: set the thread-local VC so Model.call's
780
- # strict-mode gate passes, attach the VC to the instance, and
781
- # apply the :view filter with two bypasses for materialization
782
- # contexts where filtering would be wrong or break callers:
783
- # - in_policy_eval?: policies that traverse protected data
784
- # need raw rows so their checks (e.g. membership) aren't
785
- # short-circuited by recursive :view filtering.
786
- # - EAGER_VC_KEY: Sequel's eager-load attachment block
787
- # dereferences each record to bucket by FK and would crash
788
- # on nils; the association reader filters at read time.
694
+ # Stores the ViewerContext in a Thread-local that Model.call
695
+ # can retreive. Materializes the model, and then checks the view
696
+ # policy. If the model is being materialized within the context of
697
+ # checking a policy this is bypassed, because policies often need to
698
+ # check data that a VC might not have permission to see. The check is also
699
+ # bypassed for eager loads, and checked on the association.
789
700
  sig { returns(T.untyped) }
790
701
  def row_proc
791
702
  vc = opts[:viewer_context]
@@ -817,25 +728,18 @@ module Sequel
817
728
  end
818
729
  end
819
730
 
820
- # Override all to filter out nil results from privacy checks
821
731
  sig { returns(T::Array[T.attached_class]) }
822
732
  def all
823
733
  results = super
824
734
  opts[:viewer_context] ? results.compact : results
825
735
  end
826
736
 
827
- # Sequel calls post_load after rows are fetched but before any
828
- # user block. Model's override of post_load triggers eager_load
829
- # here. Set the thread-local VC around that call so each
830
- # association's injected :eager_block can wrap its child dataset
831
- # with for_vc. Children are then materialized with VC attached
832
- # but without :view filtering (see Model.call), so Sequel's
833
- # attachment block doesn't choke on nils; the accessor wrapper
834
- # filters at read time.
835
- #
836
- # Parents filtered to nil by the :view policy must be dropped
837
- # before eager_load runs — its attachment code dereferences each
838
- # record, which nil would break.
737
+ # Sequel's Model#post_load triggers eager_load. We expose the VC
738
+ # via EAGER_VC_KEY around that call so the :eager_block injected
739
+ # in ClassMethods#associate can wrap each child dataset with
740
+ # for_vc. Parents already filtered to nil by row_proc must be
741
+ # compacted first eager_load's attachment code can't handle
742
+ # nil records.
839
743
  sig { params(all_records: T.untyped).returns(T.untyped) }
840
744
  def post_load(all_records)
841
745
  vc = opts[:viewer_context]
@@ -852,7 +756,6 @@ module Sequel
852
756
  end
853
757
  end
854
758
 
855
- # Create a new model instance with the viewer context attached
856
759
  sig { params(values: T::Hash[Symbol, T.untyped]).returns(T.attached_class) }
857
760
  def new(values = {})
858
761
  instance = T.unsafe(model).new(values)
@@ -862,7 +765,6 @@ module Sequel
862
765
  instance
863
766
  end
864
767
 
865
- # Create and save a new model instance with the viewer context attached
866
768
  sig { params(values: T::Hash[Symbol, T.untyped]).returns(T.attached_class) }
867
769
  def create(values = {})
868
770
  T.cast(new(values), Sequel::Model).save
@@ -3,10 +3,9 @@
3
3
 
4
4
  module Sequel
5
5
  module Privacy
6
- # Built-in policies that ship with the gem.
7
- # Applications should define their own policies using PolicyDSL.
8
6
  module BuiltInPolicies
9
- # Always deny access. Should be the last policy in every chain (fail-secure).
7
+ # Always deny access. You should specify this policy at the end of every chain, but the framework
8
+ # currently appends it. This could change; I'm not sure about this yet.
10
9
  AlwaysDeny = Policy.create(
11
10
  :AlwaysDeny,
12
11
  -> { :deny },
@@ -14,7 +13,6 @@ module Sequel
14
13
  cacheable: true
15
14
  )
16
15
 
17
- # Always allow access. Use sparingly.
18
16
  AlwaysAllow = Policy.create(
19
17
  :AlwaysAllow,
20
18
  -> { :allow },
@@ -3,26 +3,23 @@
3
3
 
4
4
  module Sequel
5
5
  module Privacy
6
- # In-memory cache for policy evaluation results.
7
- # Should be cleared between requests (e.g., via Rack middleware).
6
+ # In-memory cache for policy evaluation results. Clear between
7
+ # requests (e.g. via Rack middleware).
8
8
  class << self
9
9
  extend T::Sig
10
10
 
11
- # Returns the in-memory cache Hash for policy results.
12
11
  sig { returns(T::Hash[Integer, Symbol]) }
13
12
  def cache
14
13
  @cache ||= T.let({}, T.nilable(T::Hash[Integer, Symbol]))
15
14
  end
16
15
 
17
- # Returns the hash tracking single-match optimizations.
18
- # Key: [policy, actor, viewer_context].hash
19
- # Value: subject.hash that matched
16
+ # Tracks single-match optimization state.
17
+ # Key: [policy, actor, viewer_context].hash → Value: subject.hash
20
18
  sig { returns(T::Hash[Integer, Integer]) }
21
19
  def single_matches
22
20
  @single_matches ||= T.let({}, T.nilable(T::Hash[Integer, Integer]))
23
21
  end
24
22
 
25
- # Clear all caches. Call this between requests.
26
23
  sig { void }
27
24
  def clear_cache!
28
25
  @cache = {}
@@ -3,8 +3,6 @@
3
3
 
4
4
  module Sequel
5
5
  module Privacy
6
- # The Enforcer evaluates policy chains to determine if an action is allowed.
7
- # It handles caching, single-match optimization, and policy combinators.
8
6
  module Enforcer
9
7
  extend T::Sig
10
8
 
@@ -18,7 +16,6 @@ module Sequel
18
16
  class << self
19
17
  extend T::Sig
20
18
 
21
- # Returns the centralized logger from Sequel::Privacy.logger
22
19
  sig { returns(T.untyped) }
23
20
  def logger
24
21
  Sequel::Privacy.logger
@@ -30,13 +27,8 @@ module Sequel
30
27
  Thread.current[EVAL_KEY] == true
31
28
  end
32
29
 
33
- # Main entry point for policy evaluation.
34
- #
35
- # @param policies [Array<Policy, Proc>] The policy chain to evaluate
36
- # @param subject [Sequel::Model] The object being accessed
37
- # @param viewer_context [ViewerContext] Who is accessing the object
38
- # @param direct_object [Sequel::Model, nil] Optional additional context object
39
- # @return [Boolean] true if access is allowed, false otherwise
30
+ # Evaluates a policy chain against (subject, viewer_context, direct_object)
31
+ # and returns whether access is allowed.
40
32
  sig do
41
33
  params(
42
34
  policies: TPolicyArray,
@@ -50,7 +42,6 @@ module Sequel
50
42
  Thread.current[EVAL_KEY] = true
51
43
 
52
44
  begin
53
- # All-powerful and omniscient contexts bypass all checks
54
45
  if viewer_context.is_a?(AllPowerfulVC)
55
46
  logger&.warn('BYPASS: All-powerful viewer context bypasses all privacy rules.')
56
47
  return true
@@ -68,7 +59,7 @@ module Sequel
68
59
  policies = [BuiltInPolicies::AlwaysDeny]
69
60
  end
70
61
 
71
- # Ensure policy chain ends with AlwaysDeny (fail-secure)
62
+ # Fail-secure: every chain ends with AlwaysDeny.
72
63
  unless policies.last == BuiltInPolicies::AlwaysDeny
73
64
  logger&.warn { 'Policy chain should end with AlwaysDeny. Appending it.' }
74
65
  policies = policies.dup << BuiltInPolicies::AlwaysDeny
@@ -138,9 +129,7 @@ module Sequel
138
129
  ).returns(Symbol)
139
130
  end
140
131
  def self.evaluate_child_policies(child_policies, subject, actor, viewer_context, direct_object)
141
- unless child_policies.all? { |c| c.is_a?(Proc) }
142
- Kernel.raise "Policy combinator contains non-policy members"
143
- end
132
+ Kernel.raise 'Policy combinator contains non-policy members' unless child_policies.all? { |c| c.is_a?(Proc) }
144
133
 
145
134
  results = child_policies.map do |child_policy|
146
135
  policy_result(child_policy, subject, actor, viewer_context, direct_object)
@@ -152,7 +141,6 @@ module Sequel
152
141
  :pass
153
142
  end
154
143
 
155
- # Evaluate a single policy and return its result
156
144
  sig do
157
145
  params(
158
146
  uncasted_policy: T.any(TPolicy, Proc),
@@ -168,7 +156,6 @@ module Sequel
168
156
 
169
157
  policy = T.cast(uncasted_policy, TPolicy, checked: false)
170
158
 
171
- # Single-match optimization
172
159
  if policy.single_match?
173
160
  match_key = [policy, actor, viewer_context].hash
174
161
  if (matched = Sequel::Privacy.single_matches[match_key]) && matched != subject.hash
@@ -177,7 +164,6 @@ module Sequel
177
164
  end
178
165
  end
179
166
 
180
- # Check cache
181
167
  cache_key = compute_cache_key(policy, subject, actor, viewer_context, direct_object)
182
168
  if !skipped_from_single_match && policy.cacheable? && Sequel::Privacy.cache.key?(cache_key)
183
169
  from_cache = true
@@ -185,24 +171,15 @@ module Sequel
185
171
  Kernel.raise InvalidPolicyOutcomeError unless result && valid_outcome?(result)
186
172
  end
187
173
 
188
- # Execute policy if not cached
189
174
  result ||= execute_policy(policy, subject, actor, direct_object)
190
175
  result ||= :pass
191
176
 
192
- # Handle combinator results
193
- if result.is_a?(Array)
194
- result = evaluate_child_policies(result, subject, actor, viewer_context, direct_object)
195
- end
177
+ result = evaluate_child_policies(result, subject, actor, viewer_context, direct_object) if result.is_a?(Array)
196
178
 
197
- # Cache result
198
- if policy.cacheable? && !from_cache
199
- Sequel::Privacy.cache[cache_key] = result
200
- end
179
+ Sequel::Privacy.cache[cache_key] = result if policy.cacheable? && !from_cache
201
180
 
202
- # Log result
203
181
  log_result(policy, result, actor, subject, from_cache, skipped_from_single_match)
204
182
 
205
- # Record single-match
206
183
  if policy.single_match? && result == :allow
207
184
  Sequel::Privacy.single_matches[[policy, actor, viewer_context].hash] = subject.hash
208
185
  end
@@ -223,13 +200,10 @@ module Sequel
223
200
  ).returns(T.untyped)
224
201
  end
225
202
  def self.execute_policy(policy, subject, actor, direct_object)
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?
231
- return :deny
232
- end
203
+ # Arity 1 policies expect an actor as the first arg; an
204
+ # anonymous viewer auto-denies unless the policy opts in with
205
+ # `allow_anonymous: true` (for subject-only state gates).
206
+ return :deny if !actor && policy.arity >= 1 && !policy.allow_anonymous?
233
207
 
234
208
  case policy.arity
235
209
  when 0
@@ -259,14 +233,14 @@ module Sequel
259
233
  actor_id = actor ? actor.id : 'anonymous'
260
234
  logger.debug do
261
235
  msg = "#{result.to_s.upcase}: #{policy.policy_name || 'anonymous'} for actor[#{actor_id}] on #{subject.class}[#{subject_id(subject)}]"
262
- msg += " (cached)" if from_cache
263
- msg += " (skipped: single_match)" if skipped
236
+ msg += ' (cached)' if from_cache
237
+ msg += ' (skipped: single_match)' if skipped
264
238
  msg
265
239
  end
266
240
 
267
- if policy.comment && %i[deny allow].include?(result)
268
- logger.debug { " ⮑ #{policy.comment}" }
269
- end
241
+ return unless policy.comment && %i[deny allow].include?(result)
242
+
243
+ logger.debug { " ⮑ #{policy.comment}" }
270
244
  end
271
245
 
272
246
  sig { params(subject: TPolicySubject).returns(T.untyped) }
@@ -3,8 +3,7 @@
3
3
 
4
4
  module Sequel
5
5
  module Privacy
6
- # Interface that actors (typically User/Member models) must implement
7
- # to be used with the privacy system.
6
+ # Interface for actors used in viewer contexts (typically User/Member).
8
7
  module IActor
9
8
  extend T::Sig
10
9
  extend T::Helpers
@@ -27,8 +27,6 @@ module Sequel
27
27
 
28
28
  VALID_CACHE_BY = T.let(%i[actor subject direct_object].freeze, T::Array[Symbol])
29
29
 
30
- # Factory method for creating policies. Accepts procs of any arity
31
- # (0–3 args) returning :allow, :deny, :pass, or an Array of policies.
32
30
  sig do
33
31
  params(
34
32
  policy_name: Symbol,
@@ -52,24 +50,23 @@ module Sequel
52
50
  )
53
51
  end
54
52
 
55
- # Configure the policy after creation
53
+ # Configure the policy after creation, normally done with the shorthand `policy` call.
56
54
  #
57
55
  # @param policy_name [Symbol, nil] Human-readable name for logging
58
56
  # @param comment [String, nil] Description of what this policy does
59
57
  # @param cacheable [Boolean] Whether results can be cached (default: true)
60
58
  # @param single_match [Boolean] Whether only one subject/actor pair can match (default: false)
61
59
  # @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
60
+ # dimensions. By default the key is derived from the policy's arity,
61
+ # but you might want to pass a subset of `:actor, :subject, :direct_object`
62
+ # to cache by only those; useful when the policy ignores inputs (e.g. an
63
+ # "is-admin" check that takes `(actor, subject)` but only looks at
67
64
  # actor should use `cache_by: :actor` to share a single entry across
68
65
  # subjects).
69
66
  # @param allow_anonymous [Boolean] If true, skip the auto-deny that
70
67
  # 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.
68
+ # anonymous viewer (nil actor). This is a bit inelegant; it'd be great
69
+ # if we could tell that an argument isn't used at all.
73
70
  def setup(policy_name: nil, comment: nil, cacheable: true, single_match: false, cache_by: nil,
74
71
  allow_anonymous: false)
75
72
  raise 'Privacy Policy is frozen' if @frozen
@@ -89,8 +86,9 @@ module Sequel
89
86
  @cacheable || false
90
87
  end
91
88
 
92
- # Single-match optimization: when true, once a policy allows for a subject/actor pair,
93
- # skip evaluation for other subjects (e.g., AllowIfActorIsSelf - only one subject matches)
89
+ # When set, once the policy allows for a given actor it short-circuits
90
+ # to :pass on every other subject useful when only one subject can
91
+ # ever match (e.g. AllowSelf).
94
92
  sig { returns(T::Boolean) }
95
93
  def single_match?
96
94
  @single_match || false
@@ -125,7 +123,6 @@ module Sequel
125
123
  end
126
124
  end
127
125
 
128
- # Type aliases for use throughout the gem
129
126
  module Sequel
130
127
  module Privacy
131
128
  TPolicy = T.type_alias { Sequel::Privacy::Policy }
@@ -20,21 +20,13 @@ module Sequel
20
20
  extend T::Sig
21
21
  extend T::Helpers
22
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`.
23
+ # `extend`ed onto another Module, so `self` at runtime is always a
24
+ # Module that responds to `const_set`.
25
25
  requires_ancestor { Module }
26
26
 
27
- # Define a new policy constant on the extending module.
28
- #
29
- # @param name [Symbol] The policy name (will become a constant)
30
- # @param lam [Proc] The policy lambda (0-3 args: actor, subject, direct_object)
31
- # @param comment [String, nil] Human-readable description
32
- # @param cacheable [Boolean] Whether results can be cached (default: true)
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.
27
+ # Define a policy constant on the extending module. See
28
+ # `Sequel::Privacy::Policy#setup` for the meaning of `cacheable`,
29
+ # `single_match`, `cache_by`, and `allow_anonymous`.
38
30
  sig do
39
31
  params(
40
32
  name: Symbol,
@@ -3,6 +3,6 @@
3
3
 
4
4
  module Sequel
5
5
  module Privacy
6
- VERSION = '0.5.1'
6
+ VERSION = '0.5.3'
7
7
  end
8
8
  end
@@ -3,43 +3,40 @@
3
3
 
4
4
  module Sequel
5
5
  module Privacy
6
- # ViewerContext represents who is viewing/accessing data.
7
- # All privacy checks require a viewer context to determine what the viewer can see.
6
+ # Represents who is viewing/accessing data. All privacy checks require
7
+ # a ViewerContext.
8
8
  class ViewerContext
9
9
  extend T::Sig
10
10
  extend T::Helpers
11
11
  abstract!
12
12
 
13
- # Create a standard viewer context for an actor
14
13
  sig { params(actor: IActor).returns(ActorVC) }
15
14
  def self.for_actor(actor)
16
15
  ActorVC.new(actor)
17
16
  end
18
17
 
19
- # Create an API-specific viewer context
20
18
  sig { params(actor: IActor).returns(APIVC) }
21
19
  def self.for_api_actor(actor)
22
20
  APIVC.new(actor)
23
21
  end
24
22
 
25
- # Create an all-powerful viewer context that bypasses all privacy checks.
26
- # Use sparingly and always provide a reason for audit logging.
23
+ # Bypasses all privacy checks; requires a reason for audit logging.
24
+ # Use sparingly.
27
25
  sig { params(reason: Symbol).returns(AllPowerfulVC) }
28
26
  def self.all_powerful(reason)
29
27
  Sequel::Privacy.logger&.info("Creating all-powerful viewer context: #{reason}")
30
28
  AllPowerfulVC.new(reason)
31
29
  end
32
30
 
33
- # Create an omniscient viewer context that can see everything but cannot mutate.
34
- # Used for system operations like authentication lookups.
31
+ # Reads any record but cannot mutate. For system operations like
32
+ # authentication lookups.
35
33
  sig { params(reason: Symbol).returns(OmniscientVC) }
36
34
  def self.omniscient(reason)
37
35
  Sequel::Privacy.logger&.debug("Creating omniscient viewer context: #{reason}")
38
36
  OmniscientVC.new(reason)
39
37
  end
40
38
 
41
- # Create an anonymous viewer context for logged-out users.
42
- # Subject to normal policy evaluation with no actor.
39
+ # No actor; subject to normal policy evaluation. For logged-out users.
43
40
  sig { returns(AnonymousVC) }
44
41
  def self.anonymous
45
42
  AnonymousVC.new
@@ -94,8 +91,6 @@ module Sequel
94
91
  attr_reader :reason
95
92
  end
96
93
 
97
- # Anonymous viewer context for logged-out users.
98
- # Has no actor - policies with arity >= 1 will auto-deny.
99
94
  class AnonymousVC < ViewerContext
100
95
  extend T::Sig
101
96
 
@@ -105,7 +100,6 @@ module Sequel
105
100
  end
106
101
  end
107
102
 
108
- # Type alias for viewer contexts
109
103
  TViewerContext = T.type_alias { ViewerContext }
110
104
  end
111
105
  end
@@ -9,15 +9,13 @@ module Sequel
9
9
  class << self
10
10
  extend T::Sig
11
11
 
12
- # Configurable logger for privacy enforcement.
13
- # Set this to your application's logger (e.g., SemanticLogger).
12
+ # Set this to your application's logger (e.g. SemanticLogger).
14
13
  sig { returns(T.untyped) }
15
14
  attr_accessor :logger
16
15
  end
17
16
  end
18
17
  end
19
18
 
20
- # Core privacy infrastructure
21
19
  require_relative 'sequel/privacy/version'
22
20
  require_relative 'sequel/privacy/errors'
23
21
  require_relative 'sequel/privacy/i_actor'
@@ -29,5 +27,5 @@ require_relative 'sequel/privacy/enforcer'
29
27
  require_relative 'sequel/privacy/built_in_policies'
30
28
  require_relative 'sequel/privacy/policy_dsl'
31
29
 
32
- # The plugin is auto-loaded by Sequel when you call `plugin :privacy`
33
- # from lib/sequel/plugins/privacy.rb
30
+ # The plugin itself lives in lib/sequel/plugins/privacy.rb and is
31
+ # auto-loaded by Sequel on `plugin :privacy`.
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.5.1
4
+ version: 0.5.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Austin Bales
@@ -130,7 +130,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
130
130
  requirements:
131
131
  - - ">="
132
132
  - !ruby/object:Gem::Version
133
- version: 3.0.0
133
+ version: 3.2.0
134
134
  required_rubygems_version: !ruby/object:Gem::Requirement
135
135
  requirements:
136
136
  - - ">="