sequel-privacy 0.4 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 651aa5392073590a594f91df25458c4e2594ae6de3d8157b53525bfe210ebeef
4
- data.tar.gz: 6addd80f151b6a1b272e5f70376ac41e18fd84e7a60cc77d68017d3c6b646dc6
3
+ metadata.gz: af7fc02c1fb57d47ba358babdebb3289c2d8eb6ec43d2fce25a2c3477a3f2ccc
4
+ data.tar.gz: a18b09c73b5b03540d2a74f985d32c99b6b2a090a714346f2c87ef5a2997252c
5
5
  SHA512:
6
- metadata.gz: cb083193065e4d0929beb64dc3f51029e375861d42f5800da2218a3699b1adcd37c16aea5dc55f3cce0d250322967bed98f3c2e53119a693d8e7e9f0111bdab6
7
- data.tar.gz: 83840dfe5cded9036995df6371c083c9ab72e983f0c3de1eee69ff7a6b56b0b4e00c3ede0c6225e71669ce3bb14d981f08095c49153c5e75bacf6ee6896c072f
6
+ metadata.gz: 9f46a3f0f253061be75dcd2fb80cf797c1be229d5fd83b1c9f1c86860621dae4536ea60b06df4e38d2d6b1e66f0e90533f0d3a75dddc48be388898ac1d8517d4
7
+ data.tar.gz: f4aee413ab143a08366b223e2c34d62165c69f1e63871eee400d6f82af3c096266e0646f3b4d3d4649a761c913e2c07cef8f852586f0c535d0af3daf1d02f9a6
@@ -191,12 +191,16 @@ module Sequel
191
191
  :"#{self}_privacy_vc"
192
192
  end
193
193
 
194
- # Override Sequel's call method - this is the lowest-level instantiation point
195
- # for ALL database-loaded records. Every path goes through here:
196
- # - Model[id], Model.first, Model.all, associations, etc.
194
+ # Override Sequel's call method to act as a strict-mode gate.
195
+ # Every database-loaded record flows through here (Model[id],
196
+ # Model.first, Model.all, associations, ...). This checks that a
197
+ # VC is in scope (or that the class opted out via
198
+ # allow_unsafe_access!) and defers VC attachment and :view
199
+ # filtering to DatasetMethods#row_proc — those are per-row
200
+ # concerns and have context-dependent bypasses (policy
201
+ # evaluation, eager-load attachment) that don't belong here.
197
202
  sig { params(values: T.untyped).returns(T.nilable(Sequel::Model)) }
198
203
  def call(values)
199
- # Check if we're in a VC context (thread-local set by for_vc)
200
204
  vc = Thread.current[privacy_vc_key]
201
205
 
202
206
  unless vc || allow_unsafe_access?
@@ -204,25 +208,7 @@ module Sequel
204
208
  "#{self} requires a ViewerContext. Use #{self}.for_vc(vc) or call #{self}.allow_unsafe_access!"
205
209
  end
206
210
 
207
- # Create the instance via parent chain
208
- instance = super
209
-
210
- # Attach VC if present
211
- if vc && instance
212
- instance.instance_variable_set(:@viewer_context, vc)
213
-
214
- # During nested policy evaluation, return raw rows so the outer
215
- # policy can traverse data (e.g. checking membership) without
216
- # recursive :view filtering.
217
- return instance if Sequel::Privacy::Enforcer.in_policy_eval?
218
-
219
- unless T.cast(instance, InstanceMethods).allow?(vc, :view)
220
- Sequel::Privacy.logger&.debug { "Privacy denied :view on #{self}[#{instance.pk}]" }
221
- return nil
222
- end
223
- end
224
-
225
- instance
211
+ super
226
212
  end
227
213
 
228
214
  # ─────────────────────────────────────────────────────────────────────
@@ -410,6 +396,8 @@ module Sequel
410
396
  # Override Sequel's associate method to wrap associations with privacy checks
411
397
  sig { params(type: Symbol, name: Symbol, opts: T.untyped, block: T.untyped).returns(T.untyped) }
412
398
  def associate(type, name, opts = {}, &block)
399
+ opts = _inject_privacy_eager_block(opts)
400
+
413
401
  # Call original to create the association
414
402
  result = super
415
403
 
@@ -417,8 +405,10 @@ module Sequel
417
405
  case type
418
406
  when :many_to_one, :one_to_one
419
407
  _override_singular_association(name)
408
+ _override_association_dataset(name)
420
409
  when :one_to_many, :many_to_many
421
410
  _override_plural_association(name)
411
+ _override_association_dataset(name)
422
412
  # Check if there are already privacy policies defined for this association
423
413
  setup_association_privacy(name) if privacy_association_policies[name]
424
414
  end
@@ -426,8 +416,51 @@ module Sequel
426
416
  result
427
417
  end
428
418
 
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).
424
+ sig { params(opts: T::Hash[Symbol, T.untyped]).returns(T::Hash[Symbol, T.untyped]) }
425
+ def _inject_privacy_eager_block(opts)
426
+ original = opts[:eager_block]
427
+ wrapped = proc do |ds|
428
+ ds = original.call(ds) if original
429
+ vc = Thread.current[DatasetMethods::EAGER_VC_KEY]
430
+ if vc && T.unsafe(ds).model.respond_to?(:privacy_vc_key)
431
+ T.unsafe(ds).for_vc(vc)
432
+ else
433
+ ds
434
+ end
435
+ end
436
+ opts.merge(eager_block: wrapped)
437
+ end
438
+
429
439
  private
430
440
 
441
+ sig { params(name: Symbol).void }
442
+ def _override_association_dataset(name)
443
+ dataset_method = :"#{name}_dataset"
444
+ return unless method_defined?(dataset_method)
445
+
446
+ original = instance_method(dataset_method)
447
+ assoc_reflection = association_reflection(name)
448
+ assoc_class = T.let(nil, T.nilable(T.class_of(Sequel::Model)))
449
+
450
+ define_method(dataset_method) do |*args|
451
+ ds = original.bind(self).(*args)
452
+ vc = instance_variable_get(:@viewer_context)
453
+ return ds unless vc
454
+
455
+ assoc_class ||= assoc_reflection.associated_class
456
+ if assoc_class.respond_to?(:privacy_vc_key) && ds.respond_to?(:for_vc)
457
+ T.unsafe(ds).for_vc(vc)
458
+ else
459
+ ds
460
+ end
461
+ end
462
+ end
463
+
431
464
  sig { params(name: Symbol).void }
432
465
  def _override_singular_association(name)
433
466
  original = instance_method(name)
@@ -732,14 +765,27 @@ module Sequel
732
765
  has_attached_class!(:out)
733
766
  requires_ancestor { Sequel::Dataset }
734
767
 
768
+ # Thread-local key for propagating the current VC to eager-load
769
+ # datasets via the :eager_block injected in ClassMethods#associate.
770
+ EAGER_VC_KEY = :sequel_privacy_eager_vc
771
+
735
772
  # Attach viewer context to dataset for privacy enforcement on materialization
736
773
  sig { params(vc: Sequel::Privacy::ViewerContext).returns(Sequel::Dataset) }
737
774
  def for_vc(vc)
738
775
  clone(viewer_context: vc)
739
776
  end
740
777
 
741
- # Override row_proc to wrap Model.call with thread-local VC.
742
- # This is the single integration point that covers all iteration methods.
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.
743
789
  sig { returns(T.untyped) }
744
790
  def row_proc
745
791
  vc = opts[:viewer_context]
@@ -751,10 +797,23 @@ module Sequel
751
797
  old_vc = Thread.current[vc_key]
752
798
  Thread.current[vc_key] = vc
753
799
  begin
754
- model_class.(values)
800
+ instance = model_class.(values)
755
801
  ensure
756
802
  Thread.current[vc_key] = old_vc
757
803
  end
804
+
805
+ next nil if instance.nil?
806
+
807
+ instance.instance_variable_set(:@viewer_context, vc)
808
+ next instance if Sequel::Privacy::Enforcer.in_policy_eval?
809
+ next instance if Thread.current[EAGER_VC_KEY]
810
+
811
+ if T.cast(instance, InstanceMethods).allow?(vc, :view)
812
+ instance
813
+ else
814
+ Sequel::Privacy.logger&.debug { "Privacy denied :view on #{model_class}[#{instance.pk}]" }
815
+ nil
816
+ end
758
817
  end
759
818
  end
760
819
 
@@ -765,6 +824,34 @@ module Sequel
765
824
  opts[:viewer_context] ? results.compact : results
766
825
  end
767
826
 
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.
839
+ sig { params(all_records: T.untyped).returns(T.untyped) }
840
+ def post_load(all_records)
841
+ vc = opts[:viewer_context]
842
+ return super unless vc && opts[:eager]
843
+
844
+ all_records.compact!
845
+
846
+ old = Thread.current[EAGER_VC_KEY]
847
+ Thread.current[EAGER_VC_KEY] = vc
848
+ begin
849
+ super
850
+ ensure
851
+ Thread.current[EAGER_VC_KEY] = old
852
+ end
853
+ end
854
+
768
855
  # Create a new model instance with the viewer context attached
769
856
  sig { params(values: T::Hash[Symbol, T.untyped]).returns(T.attached_class) }
770
857
  def new(values = {})
@@ -3,6 +3,6 @@
3
3
 
4
4
  module Sequel
5
5
  module Privacy
6
- VERSION = '0.4'
6
+ VERSION = '0.5.1'
7
7
  end
8
8
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sequel-privacy
3
3
  version: !ruby/object:Gem::Version
4
- version: '0.4'
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Austin Bales