sequel-privacy 0.6.1 → 0.7.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: 627da75fe2db0e41382bd4b6a6e44cf85bb01a3a52aef9c1c16df85b0b4af5d8
4
- data.tar.gz: e0cfd6b79e35d81a51d6897fa37f9130e5fb567f3422c0014500be6bb0d95183
3
+ metadata.gz: e06f6f240ee6789aee3cd1881290df4cdc503d0be754c94e9b2bdd516607bf22
4
+ data.tar.gz: a280c975257f069cf01db2ef9189a10e2ed2fb5af279bdb365da211485e47c71
5
5
  SHA512:
6
- metadata.gz: c1450ee72abf6ef060a7d2f8d541f3d418724cca9650370746e996427d4da438e93fa96410b47b6606ab4a41542d014b46dbf97a08e3e4623cf120b419736372
7
- data.tar.gz: 5ccadc4dfb1b84fb7ab9d3beefb2b396b2b1302239c0b63fc97a2c34a6fb9b5a025857542814167e007326a17e08e1697d56731cdff690a5c9d72d850cb96529
6
+ metadata.gz: d70745ff2e176a8d6e4ba3ad5f1c8ade864b44647392085ba86df00f5f3bdd78ca306d79776731a97c4c9aa769b5fd9009260b3f9b470c88b2ec985fde21aa46
7
+ data.tar.gz: d6f85561a19cc324e3ea99e0d0cfaef30ecb405dc6eecedeb349adb55c18596de0d8384624431a1857bbe8dda11866043093be22d4cbd34e7b8bf220b28ba3a9
data/CHANGELOG.md CHANGED
@@ -1,13 +1,17 @@
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
+ - Added `Sequel::Privacy.with_cache_scope` for nested, exception-safe policy
7
+ caches isolated between concurrent threads and fibers.
8
+
9
+ ## [0.7.0] – 2026-08-26
10
+ - Removed `#for_vc` on `Sequel::Model` instances. Instead, call `#reset_viewer_context(vc, reason)`.
11
+ - Removed `#viewer_context=` from `Sequel::Model` instances.
12
+ - Added `#use(&block)` to Omniscient and AllPowerful viewer contexts. The block receives the viewer context and the reason it was created.
13
+
14
+ ## [0.1.0]
11
15
 
12
16
  ### Added
13
17
  - 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:
@@ -398,7 +402,9 @@ Sequel::Privacy.logger = SemanticLogger['Privacy']
398
402
 
399
403
  ## Cache Management
400
404
 
401
- Policy results are cached per-request to avoid redundant evaluation. Clear between requests:
405
+ Policy results are cached per operation to avoid redundant evaluation. Wrap
406
+ each request, job, or other independent operation in a cache scope. Scopes are
407
+ isolated between concurrent threads and fibers:
402
408
 
403
409
  ```ruby
404
410
  # In Rack middleware
@@ -408,13 +414,14 @@ class PrivacyCacheMiddleware
408
414
  end
409
415
 
410
416
  def call(env)
411
- Sequel::Privacy.clear_cache!
412
- @app.call(env)
417
+ Sequel::Privacy.with_cache_scope { @app.call(env) }
413
418
  end
414
419
  end
415
420
  ```
416
421
 
417
- Or somewhere manually, like at the top of your Roda or Sinatra app:
422
+ Scopes may be nested and are restored after exceptions. `clear_cache!` clears
423
+ only the active scope. Applications not yet using scopes retain the legacy
424
+ process-global cache and can clear it manually:
418
425
 
419
426
  ```ruby
420
427
  Sequel::Privacy.clear_cache!
@@ -639,13 +639,12 @@ module Sequel
639
639
  @viewer_context = T.let(@viewer_context, T.nilable(Sequel::Privacy::ViewerContext))
640
640
  end
641
641
 
642
- sig { params(vc: T.nilable(Sequel::Privacy::ViewerContext)).returns(T.nilable(Sequel::Privacy::ViewerContext)) }
643
- def viewer_context=(vc)
644
- @viewer_context = T.let(vc, T.nilable(Sequel::Privacy::ViewerContext))
645
- end
646
642
 
647
- sig { params(vc: Sequel::Privacy::ViewerContext).returns(T.self_type) }
648
- 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
649
648
  @viewer_context = T.let(vc, T.nilable(Sequel::Privacy::ViewerContext))
650
649
  self
651
650
  end
@@ -2,28 +2,82 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  module Sequel
5
+ # Privacy enforcement and operation-scoped policy caching.
5
6
  module Privacy
6
- # In-memory cache for policy evaluation results. Clear between
7
- # requests (e.g. via Rack middleware).
7
+ # One policy cache scope. Kept separate from ViewerContext because a
8
+ # long-lived context may serve many independent operations.
9
+ class CacheScope
10
+ extend T::Sig
11
+
12
+ sig { returns(T::Hash[Integer, Symbol]) }
13
+ attr_reader :cache
14
+
15
+ sig { returns(T::Hash[Integer, Integer]) }
16
+ attr_reader :single_matches
17
+
18
+ sig { void }
19
+ def initialize
20
+ @cache = T.let({}, T::Hash[Integer, Symbol])
21
+ @single_matches = T.let({}, T::Hash[Integer, Integer])
22
+ end
23
+
24
+ sig { void }
25
+ def clear!
26
+ @cache = {}
27
+ @single_matches = {}
28
+ end
29
+ end
30
+ private_constant :CacheScope
31
+
32
+ # In-memory cache for policy evaluation results. Concurrent applications
33
+ # should wrap each independent operation in with_cache_scope.
8
34
  class << self
9
35
  extend T::Sig
10
36
 
37
+ CACHE_SCOPE_KEY = :sequel_privacy_cache_scope
38
+ private_constant :CACHE_SCOPE_KEY
39
+
40
+ sig do
41
+ type_parameters(:U)
42
+ .params(block: T.proc.returns(T.type_parameter(:U)))
43
+ .returns(T.type_parameter(:U))
44
+ end
45
+ def with_cache_scope(&block)
46
+ previous = Thread.current[CACHE_SCOPE_KEY]
47
+ Thread.current[CACHE_SCOPE_KEY] = CacheScope.new
48
+ block.()
49
+ ensure
50
+ Thread.current[CACHE_SCOPE_KEY] = previous
51
+ end
52
+
11
53
  sig { returns(T::Hash[Integer, Symbol]) }
12
54
  def cache
13
- @cache ||= T.let({}, T.nilable(T::Hash[Integer, Symbol]))
55
+ active_cache_scope&.cache || (@cache ||= T.let({}, T.nilable(T::Hash[Integer, Symbol])))
14
56
  end
15
57
 
16
58
  # Tracks single-match optimization state.
17
59
  # Key: [policy, actor, viewer_context].hash → Value: subject.hash
18
60
  sig { returns(T::Hash[Integer, Integer]) }
19
61
  def single_matches
20
- @single_matches ||= T.let({}, T.nilable(T::Hash[Integer, Integer]))
62
+ active_cache_scope&.single_matches ||
63
+ (@single_matches ||= T.let({}, T.nilable(T::Hash[Integer, Integer])))
21
64
  end
22
65
 
23
66
  sig { void }
24
67
  def clear_cache!
25
- @cache = {}
26
- @single_matches = {}
68
+ if (scope = active_cache_scope)
69
+ scope.clear!
70
+ else
71
+ @cache = {}
72
+ @single_matches = {}
73
+ end
74
+ end
75
+
76
+ private
77
+
78
+ sig { returns(T.nilable(CacheScope)) }
79
+ def active_cache_scope
80
+ T.cast(Thread.current[CACHE_SCOPE_KEY], T.nilable(CacheScope))
27
81
  end
28
82
  end
29
83
  end
@@ -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.1'
6
+ VERSION = '0.7.1'
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.1
4
+ version: 0.7.1
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.21
142
156
  specification_version: 4
143
157
  summary: Privacy enforcement plugin for Sequel models
144
158
  test_files: []