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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +20 -0
- data/LICENSE.txt +21 -0
- data/README.md +412 -0
- data/lib/sequel/plugins/privacy.rb +792 -0
- data/lib/sequel/privacy/actions.rb +40 -0
- data/lib/sequel/privacy/built_in_policies.rb +37 -0
- data/lib/sequel/privacy/cache.rb +33 -0
- data/lib/sequel/privacy/enforcer.rb +246 -0
- data/lib/sequel/privacy/errors.rb +23 -0
- data/lib/sequel/privacy/i_actor.rb +17 -0
- data/lib/sequel/privacy/policy.rb +82 -0
- data/lib/sequel/privacy/policy_dsl.rb +38 -0
- data/lib/sequel/privacy/version.rb +8 -0
- data/lib/sequel/privacy/viewer_context.rb +127 -0
- data/lib/sequel-privacy.rb +33 -0
- data/rbi/sequel_privacy.rbi +66 -0
- metadata +144 -0
|
@@ -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
|