sequel-privacy 0.5.2 → 0.5.4

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: 2e415c08d25c658e76c101a7c7ccbdbb791ef1f00deb6a81481ada2044a2b779
4
- data.tar.gz: 819dfce705006ad300e092650cd2014944989c6bec4baefb6bdd5a0fe1bd6bc1
3
+ metadata.gz: 54198780e9744c541e3915d3ac5722ccb16b119a77729b5013b854e698604511
4
+ data.tar.gz: 5219bec34c9542ce88f928bc39c1998586e6ed2c15637296dead03d958e12db1
5
5
  SHA512:
6
- metadata.gz: b875efc8f711c1403eb90e8ebc6ca5071b325c3e6e7856cc7c43f4dc43f5edb4add75a6081db527d30a184c856719a1461da50a2e6a754567a8a5886567daea8
7
- data.tar.gz: b1c2f4a0525b3d4232f4f369d483affbf3bcc84235602d116f2d8a40d80c686e5bd8a677e1367bbc53012b8341a63e787b7d502377b2322f63afdbf5f10518ec
6
+ metadata.gz: 8673ccf1e4e6cb592299c9a930ee7ff779e0343e66753a245f71f4aa26d21906c486dc1435d55af53c1c8b09c8166f24fec2e8036ce3f962598df4d8a29cadf9
7
+ data.tar.gz: 34bc11e989421e4cd7d5207fc531d7bcf17cf681c8b10c3104918c12d5d2a9eb8cdf68274dd64397c8b49ba51b6c59b3de4fd4ef73e8acbf06b1fdd671039cce
data/README.md CHANGED
@@ -110,13 +110,12 @@ member.phone # => nil if :view_phone denies
110
110
 
111
111
  ## Policy Definition
112
112
 
113
- 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.
113
+ Policies are lambdas that execute in the context of an `Actions` class, 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.
114
114
 
115
- Policies are **actor-first**. Arities map to:
116
115
  - 0 args — global decision (`-> { allow if Time.now.sunday? }`)
117
- - 1 arg — `(actor)`: role / identity checks
118
- - 2 args — `(actor, subject)`: ownership, membership
119
- - 3 args — `(actor, subject, direct_object)`: "can actor do X to subject with direct_object?"
116
+ - 1 arg — `(actor)`: Useful for role / identity checks
117
+ - 2 args — `(actor, subject)`: General purpose relationship checks
118
+ - 3 args — `(actor, subject, direct_object)`: "Allow members to remove themselves from a group they're in"
120
119
 
121
120
  Policies of arity ≥ 1 auto-deny for anonymous viewers (nil actor). Use `allow_anonymous: true` to opt out — meant for state-gate policies that examine only the subject.
122
121
 
@@ -175,7 +174,7 @@ policy :MyPolicy, ->() { ... },
175
174
 
176
175
  **`cacheable: true`** (default): Results are cached for the duration of the request, keyed by policy + arguments. Use for policies that don't depend on mutable state.
177
176
 
178
- **`single_match: true`**: Optimization for policies for which there is only one matching Actor possible for a given Subject. For example in `AllowAuthors`, since a `Post` can have only one other, it's not worth a potentially expensive check on other combinations once you've found the winner.
177
+ **`single_match: true`**: Optimization for policies for which there is only one matching Actor possible for a given Subject. For example in `AllowAuthors`, since a `Post` can have only one author, it's not worth a potentially expensive check on other combinations once you've found the winner.
179
178
 
180
179
  **`cache_by:`** (Symbol or Array of `:actor`, `:subject`, `:direct_object`): Override the cache-key dimensions. By default the key uses every input the policy receives. Pass a subset when the policy ignores some of its inputs — e.g. `AllowAdmins` takes `(actor, subject)` but only examines actor, so `cache_by: :actor` shares one entry across subjects.
181
180
 
@@ -217,9 +216,28 @@ All-Powerful VCs bypass all privacy checks and are used in situations where the
217
216
  to models. In a production setting, your application should prohibit raw Database access outside of the privacy-aware
218
217
  system, so these VCs give you an escape hatch for things like scripts while also keeping an audit trail.
219
218
 
220
- `omniscient` and `all_powerful` require a reason (symbol) for audit logging.
219
+ `omniscient` and `all_powerful` require a reason, given as a Symbol, for audit logging.
221
220
  You could also create lint rules that prevent the casual creation of these viewer contexts.
222
221
 
222
+ ```ruby
223
+ # Standard viewer (most common)
224
+ users_groups = Group.for_vc(current_vc).where(creator: current_user).all
225
+
226
+ # Anonymous viewer (logged-out users)
227
+ logged_out_vc = Sequel::Privacy::ViewerContext.anonymous
228
+ posts = Post.for_vc(logged_out_vc).where(published: true).all
229
+
230
+ # All-powerful ViewerContexts dangerously bypass all read and write checks.
231
+ admin_vc = Sequel::Privacy::ViewerContext.all_powerful(:admin_migration)
232
+ ```
233
+
234
+ ### Login, Sessions & `current_user` and `current_vc`
235
+
236
+ Unless you allow unsafe access to your User (or equivalent) model, you will need
237
+ a way to load it and create a ViewerContext for them. An Omniscient ViewerContext
238
+ is useful for this. Be sure to properly set to an Actor VC after you've logged-in
239
+ or materialized a user from the session.
240
+
223
241
  ```ruby
224
242
  # You can use an omniscient viewer context to load the user from a session
225
243
  # or however you store them. Discard this viewer context when you're done with it.
@@ -235,18 +253,9 @@ def current_user
235
253
  end
236
254
 
237
255
  def current_vc
238
- current_user&.vc || Sequel::Privacy::ViewerContext.anonymous()
256
+ current_user&.viewer_context || Sequel::Privacy::ViewerContext.anonymous()
239
257
  end
240
258
 
241
- # Standard viewer (most common)
242
- users_groups = Group.for_vc(current_vc).where(creator: current_user).all
243
-
244
- # Anonymous viewer (logged-out users)
245
- logged_out_vc = Sequel::Privacy::ViewerContext.anonymous
246
- posts = Post.for_vc(logged_out_vc).where(published: true).all
247
-
248
- # All-powerful ViewerContexts dangerously bypass all read and write checks.
249
- admin_vc = Sequel::Privacy::ViewerContext.all_powerful(:admin_migration)
250
259
  ```
251
260
 
252
261
  ## Mutation Enforcement
@@ -378,11 +387,10 @@ class PrivacyCacheMiddleware
378
387
  end
379
388
  ```
380
389
 
381
- Or manually:
390
+ Or somewhere manually, like at the top of your Roda or Sinatra app:
382
391
 
383
392
  ```ruby
384
- Sequel::Privacy.cache.clear
385
- Sequel::Privacy.single_matches.clear
393
+ Sequel::Privacy.clear_cache!
386
394
  ```
387
395
 
388
396
  ## Actor Interface
@@ -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,8 +3,8 @@
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
 
@@ -13,15 +13,13 @@ module Sequel
13
13
  @cache ||= T.let({}, T.nilable(T::Hash[Integer, Symbol]))
14
14
  end
15
15
 
16
- # Returns the hash tracking single-match optimizations.
17
- # Key: [policy, actor, viewer_context].hash
18
- # Value: subject.hash that matched
16
+ # Tracks single-match optimization state.
17
+ # Key: [policy, actor, viewer_context].hash → Value: subject.hash
19
18
  sig { returns(T::Hash[Integer, Integer]) }
20
19
  def single_matches
21
20
  @single_matches ||= T.let({}, T.nilable(T::Hash[Integer, Integer]))
22
21
  end
23
22
 
24
- # Clear all caches. Call this between requests.
25
23
  sig { void }
26
24
  def clear_cache!
27
25
  @cache = {}
@@ -16,7 +16,6 @@ module Sequel
16
16
  class << self
17
17
  extend T::Sig
18
18
 
19
- # Returns the centralized logger from Sequel::Privacy.logger
20
19
  sig { returns(T.untyped) }
21
20
  def logger
22
21
  Sequel::Privacy.logger
@@ -28,13 +27,8 @@ module Sequel
28
27
  Thread.current[EVAL_KEY] == true
29
28
  end
30
29
 
31
- # Main entry point for policy evaluation.
32
- #
33
- # @param policies [Array<Policy, Proc>] The policy chain to evaluate
34
- # @param subject [Sequel::Model] The object being accessed
35
- # @param viewer_context [ViewerContext] Who is accessing the object
36
- # @param direct_object [Sequel::Model, nil] Optional additional context object
37
- # @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.
38
32
  sig do
39
33
  params(
40
34
  policies: TPolicyArray,
@@ -48,7 +42,6 @@ module Sequel
48
42
  Thread.current[EVAL_KEY] = true
49
43
 
50
44
  begin
51
- # All-powerful and omniscient contexts bypass all checks
52
45
  if viewer_context.is_a?(AllPowerfulVC)
53
46
  logger&.warn('BYPASS: All-powerful viewer context bypasses all privacy rules.')
54
47
  return true
@@ -66,7 +59,7 @@ module Sequel
66
59
  policies = [BuiltInPolicies::AlwaysDeny]
67
60
  end
68
61
 
69
- # Ensure policy chain ends with AlwaysDeny (fail-secure)
62
+ # Fail-secure: every chain ends with AlwaysDeny.
70
63
  unless policies.last == BuiltInPolicies::AlwaysDeny
71
64
  logger&.warn { 'Policy chain should end with AlwaysDeny. Appending it.' }
72
65
  policies = policies.dup << BuiltInPolicies::AlwaysDeny
@@ -148,7 +141,6 @@ module Sequel
148
141
  :pass
149
142
  end
150
143
 
151
- # Evaluate a single policy and return its result
152
144
  sig do
153
145
  params(
154
146
  uncasted_policy: T.any(TPolicy, Proc),
@@ -164,7 +156,6 @@ module Sequel
164
156
 
165
157
  policy = T.cast(uncasted_policy, TPolicy, checked: false)
166
158
 
167
- # Single-match optimization
168
159
  if policy.single_match?
169
160
  match_key = [policy, actor, viewer_context].hash
170
161
  if (matched = Sequel::Privacy.single_matches[match_key]) && matched != subject.hash
@@ -173,7 +164,6 @@ module Sequel
173
164
  end
174
165
  end
175
166
 
176
- # Check cache
177
167
  cache_key = compute_cache_key(policy, subject, actor, viewer_context, direct_object)
178
168
  if !skipped_from_single_match && policy.cacheable? && Sequel::Privacy.cache.key?(cache_key)
179
169
  from_cache = true
@@ -181,20 +171,15 @@ module Sequel
181
171
  Kernel.raise InvalidPolicyOutcomeError unless result && valid_outcome?(result)
182
172
  end
183
173
 
184
- # Execute policy if not cached
185
174
  result ||= execute_policy(policy, subject, actor, direct_object)
186
175
  result ||= :pass
187
176
 
188
- # Handle combinator results
189
177
  result = evaluate_child_policies(result, subject, actor, viewer_context, direct_object) if result.is_a?(Array)
190
178
 
191
- # Cache result
192
179
  Sequel::Privacy.cache[cache_key] = result if policy.cacheable? && !from_cache
193
180
 
194
- # Log result
195
181
  log_result(policy, result, actor, subject, from_cache, skipped_from_single_match)
196
182
 
197
- # Record single-match
198
183
  if policy.single_match? && result == :allow
199
184
  Sequel::Privacy.single_matches[[policy, actor, viewer_context].hash] = subject.hash
200
185
  end
@@ -215,10 +200,9 @@ module Sequel
215
200
  ).returns(T.untyped)
216
201
  end
217
202
  def self.execute_policy(policy, subject, actor, direct_object)
218
- # Policies with arity >= 1 expect an actor as the first arg.
219
- # Anonymous viewers (no actor) auto-deny unless the policy opts in
220
- # with allow_anonymous: true (for state-gate policies that examine
221
- # only the subject).
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).
222
206
  return :deny if !actor && policy.arity >= 1 && !policy.allow_anonymous?
223
207
 
224
208
  case policy.arity
@@ -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.2'
6
+ VERSION = '0.5.4'
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.2
4
+ version: 0.5.4
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
  - - ">="