sequel-privacy 0.6 → 0.7.0

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: 6aa19c5eda5b7d5be5a491fd8aa877037c1b79014245d2a400f647fb7aa2d174
4
- data.tar.gz: f29f6df4a6418febe64508727df1cb8e2e611b31ea02ae3b8dda9516cdc5fd90
3
+ metadata.gz: c7ef079bb3498aa1006aa327a138bcb1735b78ae02a65b11d2ee4976d9871978
4
+ data.tar.gz: 95000199db0cbb08908c1a261152c641b71ceb7898875a9f732936662df9334e
5
5
  SHA512:
6
- metadata.gz: 9a466793f9a3ddb2c3938f6434a56a7262569820ae3a9c8fb752c0f7d163f3b8bdfad3d09f9750fe4771d28fbb8171a780e9569963429dda2edc5e0f2b2ac68a
7
- data.tar.gz: bf7059897a808ff8466b2054f632a682bbf5e8201995760999259baaac3753d7116f6fe1159ec868ca1dfc40d5166697982a47f4b8dc494615c0337fdad14f2f
6
+ metadata.gz: 6ea2e6410976a3e325264faa698a2faaf323c1aafdfe9eb3444c8cc18f51c887155227a1fe68ec119408d9679b1c4a39cc70e69611db4e6ed94edf9b93857ef2
7
+ data.tar.gz: 0271f5b12a6d376e04b5feae0eb9ee604da1258871092578878571c877b86b9ef388a60441962b31b36588c9c3e4fc9642c9b71cd0f90a8890030903e2a1bb21
data/CHANGELOG.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # Changelog
2
2
 
3
- All notable changes to this project will be documented in this file.
4
-
5
- The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
- and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
3
 
8
4
  ## [Unreleased]
9
5
 
10
- ## [0.1.0] - 2024-01-24
6
+ ## [0.7.0] 2026-08-26
7
+ - Removed `#for_vc` on `Sequel::Model` instances. Instead, call `#reset_viewer_context(vc, reason)`.
8
+ - Removed `#viewer_context=` from `Sequel::Model` instances.
9
+ - Added `#use(&block)` to Omniscient and AllPowerful viewer contexts. The block receives the viewer context and the reason it was created.
10
+
11
+ ## [0.1.0]
11
12
 
12
13
  ### Added
13
14
  - Initial release
data/README.md CHANGED
@@ -260,31 +260,35 @@ admin_vc = Sequel::Privacy::ViewerContext.all_powerful(:admin_migration)
260
260
 
261
261
  ### Login, Sessions & `current_user` and `current_vc`
262
262
 
263
- Unless you allow unsafe access to your User (or equivalent) model, you will need
264
- a way to load it and create a ViewerContext for them. An Omniscient ViewerContext
265
- is useful for this. Be sure to properly set to an Actor VC after you've logged-in
266
- or materialized a user from the session.
263
+ Unless you allow unsafe access to your User (or equivalent) model, you will need
264
+ a way to load it and create a ViewerContext for them. An Omniscient ViewerContext
265
+ is useful for this.
267
266
 
268
267
  ```ruby
269
- # You can use an omniscient viewer context to load the user from a session
270
- # or however you store them. Discard this viewer context when you're done with it.
271
- def current_user
268
+ def current_user
272
269
  return @current_user if @current_user
273
- login_vc = Sequel::Privacy::ViewerContext.omniscient(:login)
274
- user = User.for_vc(login_vc)[session_user_id]
275
- return nil unless user
276
270
 
277
- # Attach an ActorVC to the loaded user so that future calls to its fields and
278
- # associations respect privacy .
279
- @current_user ||= user.for_vc(Sequel::Privacy::ViewerContext.for_actor(user))
271
+ @current_user = Sequel::Privacy::ViewerContext.omniscient(:login).use do |vc, reason|
272
+ user = User.for_vc(vc)[session_user_id]
273
+ next nil unless user
274
+
275
+ # Give the row its own ActorVC, so its fields and associations respect
276
+ # privacy from here on — and so it can be written to. An OmniscientVC reads
277
+ # anything but refuses to mutate.
278
+ user.reset_viewer_context(Sequel::Privacy::ViewerContext.for_actor(user), reason)
279
+ end
280
280
  end
281
281
 
282
282
  def current_vc
283
283
  current_user&.viewer_context || Sequel::Privacy::ViewerContext.anonymous()
284
284
  end
285
-
286
285
  ```
287
286
 
287
+ *Warning:* Skipping the reset leaves you holding an object that cannot be
288
+ saved, but any association or field reads on it will be allowed. So you should always
289
+ discard the OmniVC / APVCs used for things like this and call `reset_viewer_context`
290
+ with a more appropriate one as soon as you can.
291
+
288
292
  ## Mutation Enforcement
289
293
 
290
294
  When a viewer context is attached, mutations are automatically checked:
@@ -371,13 +371,13 @@ module Sequel
371
371
 
372
372
  sig { params(original: T.untyped).returns(Proc) }
373
373
  def _privacy_eager_block(original)
374
- wrapped = proc do |ds|
375
- ds = original.call(ds) if original
374
+ wrapped = proc do |dataset|
375
+ dataset = original.call(dataset) if original
376
376
  vc = Thread.current[DatasetMethods::EAGER_VC_KEY]
377
- if vc && T.unsafe(ds).model.respond_to?(:privacy_vc_key)
378
- T.unsafe(ds).for_vc(vc)
377
+ if vc && T.unsafe(dataset).model.respond_to?(:privacy_vc_key)
378
+ T.unsafe(dataset).for_vc(vc).clone(privacy_eager_load: true)
379
379
  else
380
- ds
380
+ dataset
381
381
  end
382
382
  end
383
383
  wrapped
@@ -385,6 +385,16 @@ module Sequel
385
385
 
386
386
  private
387
387
 
388
+ sig { returns(T::Module[T.anything]) }
389
+ def privacy_association_wrapper
390
+ wrapper = @privacy_association_wrapper ||= T.let(
391
+ Module.new,
392
+ T.nilable(T::Module[T.anything])
393
+ )
394
+ prepend(wrapper) unless ancestors.include?(wrapper)
395
+ wrapper
396
+ end
397
+
388
398
  sig { params(type: Symbol, name: Symbol, inject_eager_block: T::Boolean).void }
389
399
  def _setup_privacy_association_readers(type, name, inject_eager_block)
390
400
  reflection = association_reflection(name)
@@ -419,12 +429,11 @@ module Sequel
419
429
  dataset_method = :"#{name}_dataset"
420
430
  return unless method_defined?(dataset_method)
421
431
 
422
- original = instance_method(dataset_method)
423
432
  assoc_reflection = association_reflection(name)
424
433
  assoc_class = T.let(nil, T.nilable(T.class_of(Sequel::Model)))
425
434
 
426
- define_method(dataset_method) do |*args|
427
- ds = original.bind(self).(*args)
435
+ privacy_association_wrapper.define_method(dataset_method) do |*args, &block|
436
+ ds = super(*args, &block)
428
437
  vc = instance_variable_get(:@viewer_context)
429
438
  return ds unless vc
430
439
 
@@ -439,12 +448,11 @@ module Sequel
439
448
 
440
449
  sig { params(name: Symbol).void }
441
450
  def _override_singular_association(name)
442
- original = instance_method(name)
443
451
  assoc_reflection = association_reflection(name)
444
452
  # Resolve lazily to handle forward references between models.
445
453
  assoc_class = T.let(nil, T.nilable(T.class_of(Sequel::Model)))
446
454
 
447
- define_method(name) do
455
+ privacy_association_wrapper.define_method(name) do |*args, &block|
448
456
  vc = instance_variable_get(:@viewer_context)
449
457
 
450
458
  if vc.nil? && !T.unsafe(self.class).allow_unsafe_access?(name)
@@ -459,12 +467,12 @@ module Sequel
459
467
  old_vc = Thread.current[vc_key]
460
468
  Thread.current[vc_key] = vc
461
469
  begin
462
- original.bind(self).()
470
+ super(*args, &block)
463
471
  ensure
464
472
  Thread.current[vc_key] = old_vc
465
473
  end
466
474
  else
467
- original.bind(self).()
475
+ super(*args, &block)
468
476
  end
469
477
 
470
478
  return nil unless obj
@@ -484,11 +492,10 @@ module Sequel
484
492
 
485
493
  sig { params(name: Symbol).void }
486
494
  def _override_plural_association(name)
487
- original = instance_method(name)
488
495
  assoc_reflection = association_reflection(name)
489
496
  assoc_class = T.let(nil, T.nilable(T.class_of(Sequel::Model)))
490
497
 
491
- define_method(name) do
498
+ privacy_association_wrapper.define_method(name) do |*args, &block|
492
499
  vc = instance_variable_get(:@viewer_context)
493
500
 
494
501
  if vc.nil? && !T.unsafe(self.class).allow_unsafe_access?(name)
@@ -503,12 +510,12 @@ module Sequel
503
510
  old_vc = Thread.current[vc_key]
504
511
  Thread.current[vc_key] = vc
505
512
  begin
506
- original.bind(self).()
513
+ super(*args, &block)
507
514
  ensure
508
515
  Thread.current[vc_key] = old_vc
509
516
  end
510
517
  else
511
- original.bind(self).()
518
+ super(*args, &block)
512
519
  end
513
520
 
514
521
  return objs unless vc
@@ -530,13 +537,13 @@ module Sequel
530
537
  sig { params(assoc_name: Symbol, singular_name: Symbol, policies: T::Array[T.untyped]).void }
531
538
  def _wrap_association_add(assoc_name, singular_name, policies)
532
539
  method_name = :"add_#{singular_name}"
533
- original = instance_method(method_name)
534
540
 
535
- define_method(method_name) do |obj|
541
+ privacy_association_wrapper.define_method(method_name) do |*args, &block|
542
+ obj = args.first
536
543
  vc = instance_variable_get(:@viewer_context)
537
544
 
538
545
  unless vc
539
- return original.bind(self).(obj) if T.unsafe(self.class).allow_unsafe_access?(assoc_name)
546
+ return super(*args, &block) if T.unsafe(self.class).allow_unsafe_access?(assoc_name)
540
547
 
541
548
  Kernel.raise Sequel::Privacy::MissingViewerContext,
542
549
  "Cannot #{method_name} without a viewer context"
@@ -554,20 +561,20 @@ module Sequel
554
561
  "Cannot #{method_name} on #{self.class}"
555
562
  end
556
563
 
557
- original.bind(self).(obj)
564
+ super(*args, &block)
558
565
  end
559
566
  end
560
567
 
561
568
  sig { params(assoc_name: Symbol, singular_name: Symbol, policies: T::Array[T.untyped]).void }
562
569
  def _wrap_association_remove(assoc_name, singular_name, policies)
563
570
  method_name = :"remove_#{singular_name}"
564
- original = instance_method(method_name)
565
571
 
566
- define_method(method_name) do |obj|
572
+ privacy_association_wrapper.define_method(method_name) do |*args, &block|
573
+ obj = args.first
567
574
  vc = instance_variable_get(:@viewer_context)
568
575
 
569
576
  unless vc
570
- return original.bind(self).(obj) if T.unsafe(self.class).allow_unsafe_access?(assoc_name)
577
+ return super(*args, &block) if T.unsafe(self.class).allow_unsafe_access?(assoc_name)
571
578
 
572
579
  Kernel.raise Sequel::Privacy::MissingViewerContext,
573
580
  "Cannot #{method_name} without a viewer context"
@@ -585,20 +592,19 @@ module Sequel
585
592
  "Cannot #{method_name} on #{self.class}"
586
593
  end
587
594
 
588
- original.bind(self).(obj)
595
+ super(*args, &block)
589
596
  end
590
597
  end
591
598
 
592
599
  sig { params(assoc_name: Symbol, plural_name: Symbol, policies: T::Array[T.untyped]).void }
593
600
  def _wrap_association_remove_all(assoc_name, plural_name, policies)
594
601
  method_name = :"remove_all_#{plural_name}"
595
- original = instance_method(method_name)
596
602
 
597
- define_method(method_name) do
603
+ privacy_association_wrapper.define_method(method_name) do |*args, &block|
598
604
  vc = instance_variable_get(:@viewer_context)
599
605
 
600
606
  unless vc
601
- return original.bind(self).() if T.unsafe(self.class).allow_unsafe_access?(assoc_name)
607
+ return super(*args, &block) if T.unsafe(self.class).allow_unsafe_access?(assoc_name)
602
608
 
603
609
  Kernel.raise Sequel::Privacy::MissingViewerContext,
604
610
  "Cannot #{method_name} without a viewer context"
@@ -616,7 +622,7 @@ module Sequel
616
622
  "Cannot #{method_name} on #{self.class}"
617
623
  end
618
624
 
619
- original.bind(self).()
625
+ super(*args, &block)
620
626
  end
621
627
  end
622
628
  end
@@ -633,13 +639,12 @@ module Sequel
633
639
  @viewer_context = T.let(@viewer_context, T.nilable(Sequel::Privacy::ViewerContext))
634
640
  end
635
641
 
636
- sig { params(vc: T.nilable(Sequel::Privacy::ViewerContext)).returns(T.nilable(Sequel::Privacy::ViewerContext)) }
637
- def viewer_context=(vc)
638
- @viewer_context = T.let(vc, T.nilable(Sequel::Privacy::ViewerContext))
639
- end
640
642
 
641
- sig { params(vc: Sequel::Privacy::ViewerContext).returns(T.self_type) }
642
- def for_vc(vc)
643
+ sig { params(vc: Sequel::Privacy::ViewerContext, reason: Symbol).returns(T.self_type) }
644
+ def reset_viewer_context(vc, reason)
645
+ Sequel::Privacy.logger&.debug do
646
+ "Resetting viewer context on #{self.class}[#{pk}] to #{vc.class.name.to_s.split('::').last} (#{reason})"
647
+ end
643
648
  @viewer_context = T.let(vc, T.nilable(Sequel::Privacy::ViewerContext))
644
649
  self
645
650
  end
@@ -747,8 +752,9 @@ module Sequel
747
752
  # can retreive. Materializes the model, and then checks the view
748
753
  # policy. If the model is being materialized within the context of
749
754
  # checking a policy this is bypassed, because policies often need to
750
- # check data that a VC might not have permission to see. The check is also
751
- # bypassed for eager loads, and checked on the association.
755
+ # check data that a VC might not have permission to see. For eager
756
+ # association loads, enforcement is deferred until association access so
757
+ # Sequel can finish attachment and populate reciprocal caches first.
752
758
  sig { returns(T.untyped) }
753
759
  def row_proc
754
760
  vc = opts[:viewer_context]
@@ -768,8 +774,8 @@ module Sequel
768
774
  next nil if instance.nil?
769
775
 
770
776
  instance.instance_variable_set(:@viewer_context, vc)
777
+ next instance if opts[:privacy_eager_load]
771
778
  next instance if Sequel::Privacy::Enforcer.in_policy_eval?
772
- next instance if Thread.current[EAGER_VC_KEY]
773
779
 
774
780
  if T.cast(instance, InstanceMethods).allow?(vc, :view)
775
781
  instance
@@ -38,6 +38,8 @@ module Sequel
38
38
  ).returns(T::Boolean)
39
39
  end
40
40
  def self.enforce(policies, subject, viewer_context, direct_object = nil)
41
+ viewer_context.assert_usable!
42
+
41
43
  saved = Thread.current[EVAL_KEY]
42
44
  Thread.current[EVAL_KEY] = true
43
45
 
@@ -15,6 +15,9 @@ module Sequel
15
15
  # Raised when an invalid viewer context is used
16
16
  class InvalidViewerContextError < StandardError; end
17
17
 
18
+ # Raised if #invalidates was called on this VC.
19
+ class InvalidatedViewerContext < InvalidViewerContextError; end
20
+
18
21
  class MissingViewerContext < StandardError; end
19
22
 
20
23
  # Raised when attempting to modify privacy settings after finalization
@@ -3,6 +3,6 @@
3
3
 
4
4
  module Sequel
5
5
  module Privacy
6
- VERSION = '0.6'
6
+ VERSION = '0.7.0'
7
7
  end
8
8
  end
@@ -41,6 +41,54 @@ module Sequel
41
41
  def self.anonymous
42
42
  AnonymousVC.new
43
43
  end
44
+
45
+ sig { returns(T::Boolean) }
46
+ def invalidated?
47
+ @invalidated = T.let(@invalidated, T.nilable(T::Boolean))
48
+ @invalidated || false
49
+ end
50
+
51
+ sig { void }
52
+ def assert_usable!
53
+ return unless invalidated?
54
+
55
+ Kernel.raise InvalidatedViewerContext,
56
+ "#{self.class.name.to_s.split('::').last} was invalidated and cannot be used again"
57
+ end
58
+ end
59
+
60
+ # Made available on OmniscientVC and AllPowerfulVCs. Transient contexts are
61
+ # invalidated when the use block exists. To help you keep these around for
62
+ # as short a time as possible.
63
+ module TransientViewerContext
64
+ extend T::Sig
65
+ extend T::Helpers
66
+
67
+ abstract!
68
+ requires_ancestor { ViewerContext }
69
+
70
+ sig { abstract.returns(Symbol) }
71
+ def reason; end
72
+
73
+ sig do
74
+ type_parameters(:U)
75
+ .params(block: T.proc.params(vc: T.untyped, reason: Symbol).returns(T.type_parameter(:U)))
76
+ .returns(T.type_parameter(:U))
77
+ end
78
+ def use(&block)
79
+ block.call(self, reason)
80
+ ensure
81
+ invalidate!
82
+ end
83
+
84
+ sig { returns(T.self_type) }
85
+ def invalidate!
86
+ unless invalidated?
87
+ Sequel::Privacy.logger&.debug("Invalidating viewer context: #{reason}")
88
+ @invalidated = T.let(true, T.nilable(T::Boolean))
89
+ end
90
+ self
91
+ end
44
92
  end
45
93
 
46
94
  # Standard viewer context with an actor (user/member)
@@ -65,6 +113,7 @@ module Sequel
65
113
  # Requires a reason for audit logging.
66
114
  class AllPowerfulVC < ViewerContext
67
115
  extend T::Sig
116
+ include TransientViewerContext
68
117
 
69
118
  sig { params(reason: Symbol).void }
70
119
  def initialize(reason)
@@ -72,7 +121,7 @@ module Sequel
72
121
  super()
73
122
  end
74
123
 
75
- sig { returns(Symbol) }
124
+ sig { override.returns(Symbol) }
76
125
  attr_reader :reason
77
126
  end
78
127
 
@@ -80,6 +129,7 @@ module Sequel
80
129
  # Used for system operations like authentication lookups.
81
130
  class OmniscientVC < ViewerContext
82
131
  extend T::Sig
132
+ include TransientViewerContext
83
133
 
84
134
  sig { params(reason: Symbol).void }
85
135
  def initialize(reason)
@@ -87,7 +137,7 @@ module Sequel
87
137
  super()
88
138
  end
89
139
 
90
- sig { returns(Symbol) }
140
+ sig { override.returns(Symbol) }
91
141
  attr_reader :reason
92
142
  end
93
143
 
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.6'
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Austin Bales
@@ -93,6 +93,20 @@ dependencies:
93
93
  - - "~>"
94
94
  - !ruby/object:Gem::Version
95
95
  version: '0.17'
96
+ - !ruby/object:Gem::Dependency
97
+ name: rubocop
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :development
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
96
110
  description: A Sequel plugin that provides declarative privacy policies and automatic
97
111
  enforcement at field access and query boundaries.
98
112
  email:
@@ -138,7 +152,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
138
152
  - !ruby/object:Gem::Version
139
153
  version: '0'
140
154
  requirements: []
141
- rubygems_version: 4.0.15
155
+ rubygems_version: 4.0.14
142
156
  specification_version: 4
143
157
  summary: Privacy enforcement plugin for Sequel models
144
158
  test_files: []