sequel-privacy 0.1.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.
@@ -0,0 +1,792 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ require 'sequel-privacy'
5
+
6
+ module Sequel
7
+ module Plugins
8
+ # Privacy plugin for Sequel models.
9
+ #
10
+ # Provides:
11
+ # - Policy definition DSL (`privacy` block)
12
+ # - Field-level privacy protection (`field` in privacy block)
13
+ # - Privacy-aware queries (`for_vc` method)
14
+ # - Automatic association privacy enforcement
15
+ #
16
+ # Usage:
17
+ # class Member < Sequel::Model
18
+ # plugin :privacy
19
+ #
20
+ # privacy do
21
+ # can :view, P::AllowSelf, P::AllowAdmins
22
+ # can :edit, P::AllowSelf, P::AllowAdmins
23
+ #
24
+ # field :email, P::AllowSelf
25
+ # field :phone, P::AllowSelf, P::AllowFriends
26
+ # end
27
+ # end
28
+ #
29
+ # # Query with privacy enforcement
30
+ # vc = Sequel::Privacy::ViewerContext.for_actor(current_user)
31
+ # members = Member.for_vc(vc).where(org_id: 1).all
32
+ #
33
+ # # Check permissions
34
+ # member.allow?(vc, :view) # => true/false
35
+ # member.email # => nil if :view_email denies
36
+ module Privacy
37
+ extend T::Sig
38
+
39
+ # Called once when plugin first loads on a model
40
+ sig { params(model: T.class_of(Sequel::Model), opts: T::Hash[Symbol, T.untyped]).void }
41
+ def self.apply(model, opts = {})
42
+ model.instance_variable_set(:@privacy_policies, {})
43
+ model.instance_variable_set(:@privacy_fields, {})
44
+ model.instance_variable_set(:@privacy_association_policies, {})
45
+ model.instance_variable_set(:@privacy_finalized, false)
46
+ model.instance_variable_set(:@allow_unsafe_access, false)
47
+ end
48
+
49
+ # Called every time plugin loads (for per-model configuration)
50
+ 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
54
+
55
+ # DSL class for defining association-level privacy policies
56
+ class AssociationPrivacyDSL
57
+ extend T::Sig
58
+
59
+ sig { params(model_class: T.untyped, assoc_name: Symbol, policy_resolver: T.proc.params(policies: T::Array[T.untyped]).returns(T::Array[T.untyped])).void }
60
+ def initialize(model_class, assoc_name, policy_resolver)
61
+ @model_class = model_class
62
+ @assoc_name = assoc_name
63
+ @policy_resolver = policy_resolver
64
+ @pending_policies = T.let({}, T::Hash[Symbol, T::Array[T.untyped]])
65
+ end
66
+
67
+ # Define policies for association actions (:add, :remove, :remove_all)
68
+ sig { params(action: Symbol, policies: T.untyped).void }
69
+ def can(action, *policies)
70
+ unless %i[add remove remove_all].include?(action)
71
+ Kernel.raise ArgumentError, "Association action must be :add, :remove, or :remove_all, got #{action.inspect}"
72
+ end
73
+
74
+ resolved = @policy_resolver.call(policies)
75
+ @pending_policies[action] ||= []
76
+ T.must(@pending_policies[action]).concat(resolved)
77
+ end
78
+
79
+ # Called after the association block is evaluated to register all policies at once
80
+ sig { void }
81
+ def finalize_association!
82
+ @pending_policies.each do |action, policies|
83
+ T.unsafe(@model_class).register_association_policies(@assoc_name, action, policies, defer_setup: true)
84
+ end
85
+ # Now set up the privacy wrappers after all policies are registered
86
+ T.unsafe(@model_class).setup_association_privacy(@assoc_name)
87
+ end
88
+ end
89
+
90
+ # DSL class for defining privacy policies in a block
91
+ class PrivacyDSL
92
+ extend T::Sig
93
+
94
+ sig { params(model_class: T.untyped).void }
95
+ def initialize(model_class)
96
+ @model_class = model_class
97
+ end
98
+
99
+ # Define policies for an action
100
+ sig { params(action: Symbol, policies: T.untyped).void }
101
+ def can(action, *policies)
102
+ resolved = resolve_policies(policies)
103
+ T.unsafe(@model_class).register_policies(action, resolved)
104
+ end
105
+
106
+ # Define a protected field with its policies
107
+ sig { params(name: Symbol, policies: T.untyped).void }
108
+ def field(name, *policies)
109
+ resolved = resolve_policies(policies)
110
+ policy_name = :"view_#{name}"
111
+ T.unsafe(@model_class).register_policies(policy_name, resolved)
112
+ T.unsafe(@model_class).register_protected_field(name, policy_name)
113
+ end
114
+
115
+ # Define policies for an association
116
+ #
117
+ # Example:
118
+ # association :members do
119
+ # can :add, AllowGroupAdmin, AllowSelfJoin
120
+ # can :remove, AllowGroupAdmin, AllowSelfRemove
121
+ # can :remove_all, AllowGroupAdmin
122
+ # end
123
+ sig { params(name: Symbol, block: T.proc.void).void }
124
+ def association(name, &block)
125
+ resolver = ->(policies) { resolve_policies(policies) }
126
+ dsl = AssociationPrivacyDSL.new(@model_class, name, resolver)
127
+ dsl.instance_eval(&block)
128
+ dsl.finalize_association!
129
+ end
130
+
131
+ # Finalize privacy settings (no more changes allowed)
132
+ sig { void }
133
+ def finalize!
134
+ T.unsafe(@model_class).finalize_privacy!
135
+ end
136
+
137
+ private
138
+
139
+ sig { params(policies: T::Array[T.untyped]).returns(T::Array[T.untyped]) }
140
+ def resolve_policies(policies)
141
+ policies.map do |p|
142
+ case p
143
+ when Sequel::Privacy::Policy, Proc
144
+ p
145
+ else
146
+ Kernel.raise ArgumentError, "Invalid policy: #{p.inspect}"
147
+ end
148
+ end
149
+ end
150
+ end
151
+
152
+ module ClassMethods
153
+ extend T::Sig
154
+ extend T::Helpers
155
+
156
+ requires_ancestor { T.class_of(Sequel::Model) }
157
+
158
+ # Register inherited instance variables for proper subclass handling
159
+ Sequel::Plugins.inherited_instance_variables(
160
+ self,
161
+ :@privacy_policies => :dup,
162
+ :@privacy_fields => :dup,
163
+ :@privacy_association_policies => :dup,
164
+ :@privacy_finalized => nil,
165
+ :@allow_unsafe_access => nil
166
+ )
167
+
168
+ # ─────────────────────────────────────────────────────────────────────
169
+ # Strict Mode Enforcement
170
+ # ─────────────────────────────────────────────────────────────────────
171
+
172
+ # Allow this model to be accessed without a ViewerContext.
173
+ # Use during migration to gradually enable strict mode.
174
+ sig { void }
175
+ def allow_unsafe_access!
176
+ @allow_unsafe_access = T.let(true, T.nilable(T::Boolean))
177
+ Sequel::Privacy.logger&.warn("#{self} allows unsafe access - migrate to use for_vc()")
178
+ end
179
+
180
+ sig { returns(T::Boolean) }
181
+ def allow_unsafe_access?
182
+ @allow_unsafe_access == true
183
+ end
184
+
185
+ # Thread-local key for storing the current ViewerContext during row processing
186
+ sig { returns(Symbol) }
187
+ def privacy_vc_key
188
+ :"#{self}_privacy_vc"
189
+ end
190
+
191
+ # Override Sequel's call method - this is the lowest-level instantiation point
192
+ # for ALL database-loaded records. Every path goes through here:
193
+ # - Model[id], Model.first, Model.all, associations, etc.
194
+ sig { params(values: T.untyped).returns(T.nilable(Sequel::Model)) }
195
+ def call(values)
196
+ # Check if we're in a VC context (thread-local set by for_vc)
197
+ vc = Thread.current[privacy_vc_key]
198
+
199
+ unless vc || allow_unsafe_access?
200
+ Kernel.raise Sequel::Privacy::MissingViewerContext,
201
+ "#{self} requires a ViewerContext. Use #{self}.for_vc(vc) or call #{self}.allow_unsafe_access!"
202
+ end
203
+
204
+ # Create the instance via parent chain
205
+ instance = super
206
+
207
+ # Attach VC if present
208
+ if vc && instance
209
+ instance.instance_variable_set(:@viewer_context, vc)
210
+
211
+ # Check :view policy (skip for InternalPolicyEvaluationVC - used during policy evaluation)
212
+ unless vc.is_a?(Sequel::Privacy::InternalPolicyEvaluationVC)
213
+ unless instance.allow?(vc, :view)
214
+ Sequel::Privacy.logger&.debug { "Privacy denied :view on #{self}[#{instance.pk}]" }
215
+ return nil # Filtered out
216
+ end
217
+ end
218
+ end
219
+
220
+ instance
221
+ end
222
+
223
+ # ─────────────────────────────────────────────────────────────────────
224
+ # Policy Definition DSL
225
+ # ─────────────────────────────────────────────────────────────────────
226
+
227
+ sig { returns(T::Hash[Symbol, T::Array[T.untyped]]) }
228
+ def privacy_policies
229
+ @privacy_policies ||= T.let({}, T.nilable(T::Hash[Symbol, T::Array[T.untyped]]))
230
+ end
231
+
232
+ sig { returns(T::Hash[Symbol, Symbol]) }
233
+ def privacy_fields
234
+ @privacy_fields ||= T.let({}, T.nilable(T::Hash[Symbol, Symbol]))
235
+ end
236
+
237
+ # Returns association policies: { assoc_name => { action => [policies] } }
238
+ sig { returns(T::Hash[Symbol, T::Hash[Symbol, T::Array[T.untyped]]]) }
239
+ def privacy_association_policies
240
+ @privacy_association_policies ||= T.let({}, T.nilable(T::Hash[Symbol, T::Hash[Symbol, T::Array[T.untyped]]]))
241
+ end
242
+
243
+ sig { returns(T::Boolean) }
244
+ def privacy_finalized?
245
+ @privacy_finalized == true
246
+ end
247
+
248
+ # DSL entry point for defining privacy policies
249
+ #
250
+ # @yield Block evaluated in context of PrivacyDSL
251
+ #
252
+ # Example:
253
+ # privacy do
254
+ # can :view, P::AllowMembers
255
+ # can :edit, P::AllowSelf, P::AllowAdmins
256
+ # field :email, P::AllowSelf
257
+ # end
258
+ sig { params(block: T.proc.void).void }
259
+ def privacy(&block)
260
+ if privacy_finalized?
261
+ Kernel.raise Sequel::Privacy::PrivacyAlreadyFinalizedError, "Privacy already finalized for #{self}"
262
+ end
263
+
264
+ dsl = PrivacyDSL.new(self)
265
+ dsl.instance_eval(&block)
266
+ end
267
+
268
+ # Register policies for an action (called by PrivacyDSL)
269
+ sig { params(action: Symbol, policies: T::Array[T.untyped]).void }
270
+ def register_policies(action, policies)
271
+ if privacy_finalized?
272
+ Kernel.raise Sequel::Privacy::PrivacyAlreadyFinalizedError, "Privacy already finalized for #{self}"
273
+ end
274
+
275
+ privacy_policies[action] ||= []
276
+ T.must(privacy_policies[action]).concat(policies)
277
+ end
278
+
279
+ # Register a protected field (called by PrivacyDSL)
280
+ sig { params(field: Symbol, policy_name: Symbol).void }
281
+ def register_protected_field(field, policy_name)
282
+ if privacy_finalized?
283
+ Kernel.raise Sequel::Privacy::PrivacyAlreadyFinalizedError, "Privacy already finalized for #{self}"
284
+ end
285
+
286
+ privacy_fields[field] = policy_name
287
+
288
+ # Store original method
289
+ original_method = instance_method(field)
290
+
291
+ # Override the field getter
292
+ define_method(field) do
293
+ vc = instance_variable_get(:@viewer_context)
294
+
295
+ # Require VC for protected field access
296
+ unless vc
297
+ Kernel.raise Sequel::Privacy::MissingViewerContext,
298
+ "#{self.class}##{field} requires a ViewerContext"
299
+ end
300
+
301
+ value = original_method.bind(self).call
302
+
303
+ # InternalPolicyEvaluationVC = return raw value (for policy checks)
304
+ return value if vc.is_a?(Sequel::Privacy::InternalPolicyEvaluationVC)
305
+
306
+ # Check privacy policy
307
+ if T.unsafe(self).allow?(vc, policy_name)
308
+ value
309
+ else
310
+ nil
311
+ end
312
+ end
313
+ end
314
+
315
+ # Register association policies (called by AssociationPrivacyDSL)
316
+ # @param defer_setup [Boolean] If true, don't set up wrappers yet (caller will call setup_association_privacy)
317
+ sig { params(assoc_name: Symbol, action: Symbol, policies: T::Array[T.untyped], defer_setup: T::Boolean).void }
318
+ def register_association_policies(assoc_name, action, policies, defer_setup: false)
319
+ if privacy_finalized?
320
+ Kernel.raise "Privacy policies have been finalized for #{self}"
321
+ end
322
+
323
+ privacy_association_policies[assoc_name] ||= {}
324
+ assoc_hash = T.must(privacy_association_policies[assoc_name])
325
+ assoc_hash[action] ||= []
326
+ T.must(assoc_hash[action]).concat(policies)
327
+
328
+ # Set up the association method overrides if the association exists (unless deferred)
329
+ setup_association_privacy(assoc_name) if !defer_setup && association_reflection(assoc_name)
330
+ end
331
+
332
+ # Set up privacy-wrapped add_*/remove_*/remove_all_* methods for an association
333
+ # This is called after all policies for an association have been registered
334
+ sig { params(assoc_name: Symbol).void }
335
+ def setup_association_privacy(assoc_name)
336
+ assoc_policies = privacy_association_policies[assoc_name]
337
+ return unless assoc_policies
338
+
339
+ reflection = association_reflection(assoc_name)
340
+ return unless reflection
341
+
342
+ # Track which associations have been wrapped to avoid double-wrapping
343
+ @_wrapped_associations ||= T.let({}, T.nilable(T::Hash[Symbol, T::Boolean]))
344
+ return if @_wrapped_associations[assoc_name]
345
+ @_wrapped_associations[assoc_name] = true
346
+
347
+ # Determine the singular name for method naming
348
+ # For many_to_many :members, methods are add_member, remove_member
349
+ # For one_to_many :memberships, methods are add_membership, remove_membership
350
+ singular_name = reflection[:name].to_s.chomp('s').to_sym
351
+
352
+ # Wrap add_* method if :add policy exists
353
+ add_policies = assoc_policies[:add]
354
+ if add_policies && method_defined?(:"add_#{singular_name}")
355
+ _wrap_association_add(assoc_name, singular_name, add_policies)
356
+ end
357
+
358
+ # Wrap remove_* method if :remove policy exists
359
+ remove_policies = assoc_policies[:remove]
360
+ if remove_policies && method_defined?(:"remove_#{singular_name}")
361
+ _wrap_association_remove(assoc_name, singular_name, remove_policies)
362
+ end
363
+
364
+ # Wrap remove_all_* method if :remove_all policy exists
365
+ remove_all_policies = assoc_policies[:remove_all]
366
+ if remove_all_policies && method_defined?(:"remove_all_#{reflection[:name]}")
367
+ _wrap_association_remove_all(assoc_name, reflection[:name], remove_all_policies)
368
+ end
369
+ end
370
+
371
+ # Finalize privacy settings (no more changes allowed)
372
+ # TODO: Explore automatic finalization on first query
373
+ sig { void }
374
+ def finalize_privacy!
375
+ @privacy_finalized = T.let(true, T.nilable(T::Boolean))
376
+ end
377
+
378
+ # ─────────────────────────────────────────────────────────────────────
379
+ # Deprecated Methods (for backwards compatibility)
380
+ # ─────────────────────────────────────────────────────────────────────
381
+
382
+ # @deprecated Use `privacy do; can :action, ...; end` instead
383
+ sig { params(action: Symbol, policy_chain: T.untyped).void }
384
+ def policies(action, *policy_chain)
385
+ Kernel.warn "DEPRECATED: #{self}.policies is deprecated. Use `privacy do; can :#{action}, ...; end` instead"
386
+ register_policies(action, policy_chain)
387
+ end
388
+
389
+ # @deprecated Use `privacy do; field :name, ...; end` instead
390
+ sig { params(field: Symbol, policy: T.nilable(Symbol)).void }
391
+ def protect_field(field, policy: nil)
392
+ Kernel.warn "DEPRECATED: #{self}.protect_field is deprecated. Use `privacy do; field :#{field}, ...; end` instead"
393
+ policy_name = policy || :"view_#{field}"
394
+ # Need to also register the policy if not already defined
395
+ register_protected_field(field, policy_name)
396
+ end
397
+
398
+ # Create a privacy-aware dataset
399
+ sig { params(vc: Sequel::Privacy::ViewerContext).returns(Sequel::Dataset) }
400
+ def for_vc(vc)
401
+ dataset.for_vc(vc)
402
+ end
403
+
404
+ # ─────────────────────────────────────────────────────────────────────
405
+ # Association Privacy (hooks into association creation)
406
+ # ─────────────────────────────────────────────────────────────────────
407
+
408
+ # Override Sequel's associate method to wrap associations with privacy checks
409
+ sig { params(type: Symbol, name: Symbol, opts: T.untyped, block: T.untyped).returns(T.untyped) }
410
+ def associate(type, name, opts = {}, &block)
411
+ # Call original to create the association
412
+ result = super
413
+
414
+ # Wrap the association method with privacy checks
415
+ case type
416
+ when :many_to_one, :one_to_one
417
+ _override_singular_association(name)
418
+ when :one_to_many, :many_to_many
419
+ _override_plural_association(name)
420
+ # Check if there are already privacy policies defined for this association
421
+ setup_association_privacy(name) if privacy_association_policies[name]
422
+ end
423
+
424
+ result
425
+ end
426
+
427
+ private
428
+
429
+ sig { params(name: Symbol).void }
430
+ def _override_singular_association(name)
431
+ original = instance_method(name)
432
+ assoc_reflection = association_reflection(name)
433
+ assoc_class = T.let(nil, T.nilable(T.class_of(Sequel::Model)))
434
+
435
+ define_method(name) do
436
+ vc = instance_variable_get(:@viewer_context)
437
+
438
+ # Determine associated class (lazily, to handle forward references)
439
+ assoc_class ||= assoc_reflection.associated_class
440
+
441
+ # Load association with VC context set if available
442
+ obj = if vc && assoc_class.respond_to?(:privacy_vc_key)
443
+ vc_key = assoc_class.privacy_vc_key
444
+ old_vc = Thread.current[vc_key]
445
+ Thread.current[vc_key] = vc
446
+ begin
447
+ original.bind(self).call
448
+ ensure
449
+ Thread.current[vc_key] = old_vc
450
+ end
451
+ else
452
+ original.bind(self).call
453
+ end
454
+
455
+ return nil unless obj
456
+ return obj unless vc
457
+
458
+ # InternalPolicyEvaluationVC = return raw data (for policy checks)
459
+ # This allows policies to access associations without filtering
460
+ return obj if vc.is_a?(Sequel::Privacy::InternalPolicyEvaluationVC)
461
+
462
+ # Attach viewer context to associated object
463
+ obj.instance_variable_set(:@viewer_context, vc) if obj.respond_to?(:allow?)
464
+
465
+ # Check :view policy on associated object
466
+ if obj.respond_to?(:allow?) && !obj.allow?(vc, :view)
467
+ nil
468
+ else
469
+ obj
470
+ end
471
+ end
472
+ end
473
+
474
+ sig { params(name: Symbol).void }
475
+ def _override_plural_association(name)
476
+ original = instance_method(name)
477
+ assoc_reflection = association_reflection(name)
478
+ assoc_class = T.let(nil, T.nilable(T.class_of(Sequel::Model)))
479
+
480
+ define_method(name) do
481
+ vc = instance_variable_get(:@viewer_context)
482
+
483
+ # Determine associated class (lazily, to handle forward references)
484
+ assoc_class ||= assoc_reflection.associated_class
485
+
486
+ # Load association with VC context set if available
487
+ objs = if vc && assoc_class.respond_to?(:privacy_vc_key)
488
+ vc_key = assoc_class.privacy_vc_key
489
+ old_vc = Thread.current[vc_key]
490
+ Thread.current[vc_key] = vc
491
+ begin
492
+ original.bind(self).call
493
+ ensure
494
+ Thread.current[vc_key] = old_vc
495
+ end
496
+ else
497
+ original.bind(self).call
498
+ end
499
+
500
+ return objs unless vc
501
+
502
+ # InternalPolicyEvaluationVC = return raw data (for policy checks like includes_member?)
503
+ # This allows policies to access associations without filtering
504
+ return objs if vc.is_a?(Sequel::Privacy::InternalPolicyEvaluationVC)
505
+
506
+ # Filter array, attaching VC and checking :view policy
507
+ objs.filter_map do |obj|
508
+ obj.instance_variable_set(:@viewer_context, vc) if obj.respond_to?(:allow?)
509
+
510
+ if obj.respond_to?(:allow?) && !obj.allow?(vc, :view)
511
+ nil
512
+ else
513
+ obj
514
+ end
515
+ end
516
+ end
517
+ end
518
+
519
+ sig { params(assoc_name: Symbol, singular_name: Symbol, policies: T::Array[T.untyped]).void }
520
+ def _wrap_association_add(assoc_name, singular_name, policies)
521
+ method_name = :"add_#{singular_name}"
522
+ original = instance_method(method_name)
523
+
524
+ define_method(method_name) do |obj|
525
+ vc = instance_variable_get(:@viewer_context)
526
+
527
+ unless vc
528
+ Kernel.raise Sequel::Privacy::MissingViewerContext,
529
+ "Cannot #{method_name} without a viewer context"
530
+ end
531
+
532
+ if vc.is_a?(Sequel::Privacy::OmniscientVC)
533
+ Kernel.raise Sequel::Privacy::Unauthorized,
534
+ "Cannot #{method_name} with OmniscientVC"
535
+ end
536
+
537
+ # Check policy with 3-arity: (subject=self, actor, direct_object=obj)
538
+ allowed = Sequel::Privacy::Enforcer.enforce(policies, self, vc, obj)
539
+
540
+ unless allowed
541
+ Kernel.raise Sequel::Privacy::Unauthorized,
542
+ "Cannot #{method_name} on #{self.class}"
543
+ end
544
+
545
+ original.bind(self).call(obj)
546
+ end
547
+ end
548
+
549
+ sig { params(assoc_name: Symbol, singular_name: Symbol, policies: T::Array[T.untyped]).void }
550
+ def _wrap_association_remove(assoc_name, singular_name, policies)
551
+ method_name = :"remove_#{singular_name}"
552
+ original = instance_method(method_name)
553
+
554
+ define_method(method_name) do |obj|
555
+ vc = instance_variable_get(:@viewer_context)
556
+
557
+ unless vc
558
+ Kernel.raise Sequel::Privacy::MissingViewerContext,
559
+ "Cannot #{method_name} without a viewer context"
560
+ end
561
+
562
+ if vc.is_a?(Sequel::Privacy::OmniscientVC)
563
+ Kernel.raise Sequel::Privacy::Unauthorized,
564
+ "Cannot #{method_name} with OmniscientVC"
565
+ end
566
+
567
+ # Check policy with 3-arity: (subject=self, actor, direct_object=obj)
568
+ allowed = Sequel::Privacy::Enforcer.enforce(policies, self, vc, obj)
569
+
570
+ unless allowed
571
+ Kernel.raise Sequel::Privacy::Unauthorized,
572
+ "Cannot #{method_name} on #{self.class}"
573
+ end
574
+
575
+ original.bind(self).call(obj)
576
+ end
577
+ end
578
+
579
+ sig { params(assoc_name: Symbol, plural_name: Symbol, policies: T::Array[T.untyped]).void }
580
+ def _wrap_association_remove_all(assoc_name, plural_name, policies)
581
+ method_name = :"remove_all_#{plural_name}"
582
+ original = instance_method(method_name)
583
+
584
+ define_method(method_name) do
585
+ vc = instance_variable_get(:@viewer_context)
586
+
587
+ unless vc
588
+ Kernel.raise Sequel::Privacy::MissingViewerContext,
589
+ "Cannot #{method_name} without a viewer context"
590
+ end
591
+
592
+ if vc.is_a?(Sequel::Privacy::OmniscientVC)
593
+ Kernel.raise Sequel::Privacy::Unauthorized,
594
+ "Cannot #{method_name} with OmniscientVC"
595
+ end
596
+
597
+ # Check policy with 2-arity: (subject=self, actor) - no direct object for remove_all
598
+ allowed = Sequel::Privacy::Enforcer.enforce(policies, self, vc, nil)
599
+
600
+ unless allowed
601
+ Kernel.raise Sequel::Privacy::Unauthorized,
602
+ "Cannot #{method_name} on #{self.class}"
603
+ end
604
+
605
+ original.bind(self).call
606
+ end
607
+ end
608
+ end
609
+
610
+ module InstanceMethods
611
+ extend T::Sig
612
+ extend T::Helpers
613
+
614
+ requires_ancestor { Sequel::Model }
615
+ mixes_in_class_methods(ClassMethods)
616
+
617
+
618
+ sig { returns(T.nilable(Sequel::Privacy::ViewerContext)) }
619
+ def viewer_context
620
+ @viewer_context
621
+ end
622
+
623
+ sig { params(vc: T.nilable(Sequel::Privacy::ViewerContext)).returns(T.nilable(Sequel::Privacy::ViewerContext)) }
624
+ def viewer_context=(vc)
625
+ @viewer_context = vc
626
+ end
627
+
628
+ # Attach a viewer context to this model instance
629
+ sig { params(vc: Sequel::Privacy::ViewerContext).returns(T.self_type) }
630
+ def for_vc(vc)
631
+ @viewer_context = vc
632
+ self
633
+ end
634
+
635
+ # Check if the viewer is allowed to perform an action.
636
+ #
637
+ # @param vc [ViewerContext] The viewer context
638
+ # @param action [Symbol] The action to check (:view, :edit, :create, etc.)
639
+ # @param direct_object [Sequel::Model, nil] Optional additional context
640
+ # @return [Boolean]
641
+ sig do
642
+ params(
643
+ vc: Sequel::Privacy::ViewerContext,
644
+ action: Symbol,
645
+ direct_object: T.nilable(Sequel::Model)
646
+ ).returns(T::Boolean)
647
+ end
648
+ def allow?(vc, action, direct_object = nil)
649
+ policies = T.unsafe(self.class).privacy_policies[action]
650
+ unless policies
651
+ Sequel::Privacy.logger&.error("No policies defined for :#{action} on #{self.class}")
652
+ return false
653
+ end
654
+
655
+ # Use InternalPolicyEvaluationVC during policy evaluation.
656
+ # This signals to association wrappers that they should return raw data
657
+ # without filtering, allowing policies to check things like "is actor a
658
+ # member of this list?" by accessing list.members without recursively
659
+ # checking each member's :view policy.
660
+ saved_vc = @viewer_context
661
+ @viewer_context = Sequel::Privacy::InternalPolicyEvaluationVC.new
662
+ begin
663
+ Sequel::Privacy::Enforcer.enforce(policies, self, vc, direct_object)
664
+ ensure
665
+ @viewer_context = saved_vc
666
+ end
667
+ end
668
+
669
+ # Override save to check privacy policies
670
+ sig { params(opts: T.untyped).returns(T.nilable(T.self_type)) }
671
+ def save(*opts)
672
+ vc = @viewer_context
673
+
674
+ if vc.is_a?(Sequel::Privacy::OmniscientVC)
675
+ Kernel.raise Sequel::Privacy::Unauthorized, "Cannot mutate with OmniscientVC"
676
+ end
677
+
678
+ if vc
679
+ action = new? ? :create : :edit
680
+
681
+ unless allow?(vc, action)
682
+ Kernel.raise Sequel::Privacy::Unauthorized, "Cannot #{action} #{self.class}"
683
+ end
684
+
685
+ # Check field-level policies on changed fields
686
+ changed_columns.each do |field|
687
+ policy = T.unsafe(self.class).privacy_fields[field]
688
+ next unless policy
689
+
690
+ unless allow?(vc, policy)
691
+ Kernel.raise Sequel::Privacy::FieldUnauthorized,
692
+ "Cannot modify #{self.class}##{field} (policy: #{policy})"
693
+ end
694
+ end
695
+ end
696
+
697
+ super
698
+ end
699
+
700
+ # Override update to check privacy policies
701
+ sig { params(hash: T::Hash[Symbol, T.untyped]).returns(T.self_type) }
702
+ def update(hash)
703
+ vc = @viewer_context
704
+ if vc
705
+ unless allow?(vc, :edit)
706
+ Kernel.raise Sequel::Privacy::Unauthorized, "Cannot edit #{self.class}"
707
+ end
708
+
709
+ hash.each_key do |field|
710
+ policy = T.unsafe(self.class).privacy_fields[field]
711
+ next unless policy
712
+
713
+ unless allow?(vc, policy)
714
+ Kernel.raise Sequel::Privacy::FieldUnauthorized,
715
+ "Cannot modify #{self.class}##{field} (policy: #{policy})"
716
+ end
717
+ end
718
+ end
719
+
720
+ super
721
+ end
722
+
723
+ # Override delete to block OmniscientVC
724
+ sig { returns(T.self_type) }
725
+ def delete
726
+ if @viewer_context.is_a?(Sequel::Privacy::OmniscientVC)
727
+ Kernel.raise Sequel::Privacy::Unauthorized, "Cannot delete with OmniscientVC"
728
+ end
729
+ super
730
+ end
731
+ end
732
+
733
+ module DatasetMethods
734
+ extend T::Sig
735
+ extend T::Helpers
736
+ extend T::Generic
737
+
738
+ has_attached_class!(:out)
739
+ requires_ancestor { Sequel::Dataset }
740
+
741
+ # Attach viewer context to dataset for privacy enforcement on materialization
742
+ sig { params(vc: Sequel::Privacy::ViewerContext).returns(Sequel::Dataset) }
743
+ def for_vc(vc)
744
+ clone(viewer_context: vc)
745
+ end
746
+
747
+ # Override row_proc to wrap Model.call with thread-local VC.
748
+ # This is the single integration point that covers all iteration methods.
749
+ sig { returns(T.untyped) }
750
+ def row_proc
751
+ vc = opts[:viewer_context]
752
+ return super unless vc
753
+
754
+ model_class = T.unsafe(model)
755
+ vc_key = model_class.privacy_vc_key
756
+ proc do |values|
757
+ old_vc = Thread.current[vc_key]
758
+ Thread.current[vc_key] = vc
759
+ begin
760
+ model_class.call(values)
761
+ ensure
762
+ Thread.current[vc_key] = old_vc
763
+ end
764
+ end
765
+ end
766
+
767
+ # Override all to filter out nil results from privacy checks
768
+ sig { returns(T::Array[T.attached_class]) }
769
+ def all
770
+ results = super
771
+ opts[:viewer_context] ? results.compact : results
772
+ end
773
+
774
+ # Create a new model instance with the viewer context attached
775
+ sig { params(values: T::Hash[Symbol, T.untyped]).returns(T.attached_class) }
776
+ def new(values = {})
777
+ instance = T.unsafe(model).new(values)
778
+ if (vc = opts[:viewer_context])
779
+ instance.instance_variable_set(:@viewer_context, vc)
780
+ end
781
+ instance
782
+ end
783
+
784
+ # Create and save a new model instance with the viewer context attached
785
+ sig { params(values: T::Hash[Symbol, T.untyped]).returns(T.attached_class) }
786
+ def create(values = {})
787
+ T.cast(new(values), Sequel::Model).save
788
+ end
789
+ end
790
+ end
791
+ end
792
+ end