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,40 @@
|
|
|
1
|
+
# typed: ignore
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Sequel
|
|
5
|
+
module Privacy
|
|
6
|
+
# Actions provides the DSL methods available inside policy lambdas.
|
|
7
|
+
# When policies are evaluated, they execute in the context of this struct,
|
|
8
|
+
# giving them access to allow, deny, pass, and all methods.
|
|
9
|
+
#
|
|
10
|
+
# Example:
|
|
11
|
+
# policy :AllowAdmins, ->(actor) {
|
|
12
|
+
# allow if actor.is_role?(:admin)
|
|
13
|
+
# }
|
|
14
|
+
Actions = (Struct.new do
|
|
15
|
+
extend T::Sig
|
|
16
|
+
|
|
17
|
+
sig { returns(Symbol) }
|
|
18
|
+
def allow
|
|
19
|
+
:allow
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
sig { returns(Symbol) }
|
|
23
|
+
def deny
|
|
24
|
+
:deny
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
sig { returns(Symbol) }
|
|
28
|
+
def pass
|
|
29
|
+
:pass
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Combine multiple policies - all must allow for the result to allow.
|
|
33
|
+
# Any deny results in deny. Otherwise passes.
|
|
34
|
+
sig { params(policies: T.untyped).returns(T::Array[T.untyped]) }
|
|
35
|
+
def all(*policies)
|
|
36
|
+
policies
|
|
37
|
+
end
|
|
38
|
+
end).new
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Sequel
|
|
5
|
+
module Privacy
|
|
6
|
+
# Built-in policies that ship with the gem.
|
|
7
|
+
# Applications should define their own policies using PolicyDSL.
|
|
8
|
+
module BuiltInPolicies
|
|
9
|
+
# Always deny access. Should be the last policy in every chain (fail-secure).
|
|
10
|
+
AlwaysDeny = Policy.create(
|
|
11
|
+
:AlwaysDeny,
|
|
12
|
+
-> { :deny },
|
|
13
|
+
'In the absence of other rules, this content is hidden.',
|
|
14
|
+
cacheable: true
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
# Always allow access. Use sparingly.
|
|
18
|
+
AlwaysAllow = Policy.create(
|
|
19
|
+
:AlwaysAllow,
|
|
20
|
+
-> { :allow },
|
|
21
|
+
'Always allow access.',
|
|
22
|
+
cacheable: true
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
# Pass and log - useful for debugging policy chains.
|
|
26
|
+
PassAndLog = Policy.create(
|
|
27
|
+
:PassAndLog,
|
|
28
|
+
->(subject, actor) {
|
|
29
|
+
Sequel::Privacy::Enforcer.logger&.info("PassAndLog: #{subject.class} for actor #{actor.id}")
|
|
30
|
+
:pass
|
|
31
|
+
},
|
|
32
|
+
'Log and pass to next policy.',
|
|
33
|
+
cacheable: false
|
|
34
|
+
)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Sequel
|
|
5
|
+
module Privacy
|
|
6
|
+
# In-memory cache for policy evaluation results.
|
|
7
|
+
# Should be cleared between requests (e.g., via Rack middleware).
|
|
8
|
+
class << self
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
# Returns the in-memory cache Hash for policy results.
|
|
12
|
+
sig { returns(T::Hash[Integer, Symbol]) }
|
|
13
|
+
def cache
|
|
14
|
+
@cache ||= T.let({}, T.nilable(T::Hash[Integer, Symbol]))
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# Returns the hash tracking single-match optimizations.
|
|
18
|
+
# Key: [policy, actor, viewer_context].hash
|
|
19
|
+
# Value: subject.hash that matched
|
|
20
|
+
sig { returns(T::Hash[Integer, Integer]) }
|
|
21
|
+
def single_matches
|
|
22
|
+
@single_matches ||= T.let({}, T.nilable(T::Hash[Integer, Integer]))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Clear all caches. Call this between requests.
|
|
26
|
+
sig { void }
|
|
27
|
+
def clear_cache!
|
|
28
|
+
@cache = {}
|
|
29
|
+
@single_matches = {}
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Sequel
|
|
5
|
+
module Privacy
|
|
6
|
+
# The Enforcer evaluates policy chains to determine if an action is allowed.
|
|
7
|
+
# It handles caching, single-match optimization, and policy combinators.
|
|
8
|
+
module Enforcer
|
|
9
|
+
extend T::Sig
|
|
10
|
+
|
|
11
|
+
class << self
|
|
12
|
+
extend T::Sig
|
|
13
|
+
|
|
14
|
+
# Returns the centralized logger from Sequel::Privacy.logger
|
|
15
|
+
sig { returns(T.untyped) }
|
|
16
|
+
def logger
|
|
17
|
+
Sequel::Privacy.logger
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Main entry point for policy evaluation.
|
|
22
|
+
#
|
|
23
|
+
# @param policies [Array<Policy, Proc>] The policy chain to evaluate
|
|
24
|
+
# @param subject [Sequel::Model] The object being accessed
|
|
25
|
+
# @param viewer_context [ViewerContext] Who is accessing the object
|
|
26
|
+
# @param direct_object [Sequel::Model, nil] Optional additional context object
|
|
27
|
+
# @return [Boolean] true if access is allowed, false otherwise
|
|
28
|
+
sig do
|
|
29
|
+
params(
|
|
30
|
+
policies: TPolicyArray,
|
|
31
|
+
subject: TPolicySubject,
|
|
32
|
+
viewer_context: TViewerContext,
|
|
33
|
+
direct_object: T.nilable(Sequel::Model)
|
|
34
|
+
).returns(T::Boolean)
|
|
35
|
+
end
|
|
36
|
+
def self.enforce(policies, subject, viewer_context, direct_object = nil)
|
|
37
|
+
# All-powerful and omniscient contexts bypass all checks
|
|
38
|
+
if viewer_context.is_a?(AllPowerfulVC)
|
|
39
|
+
logger&.warn('BYPASS: All-powerful viewer context bypasses all privacy rules.')
|
|
40
|
+
return true
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
if viewer_context.is_a?(OmniscientVC)
|
|
44
|
+
logger&.debug { "BYPASS: Omniscient viewer context (#{viewer_context.reason})" }
|
|
45
|
+
return true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
actor = viewer_context.is_a?(ActorVC) ? viewer_context.actor : nil
|
|
49
|
+
|
|
50
|
+
# Ensure we have policies to evaluate
|
|
51
|
+
if policies.empty?
|
|
52
|
+
logger&.error { "No policies for #{subject.class}[#{subject_id(subject)}]. Denying by default." }
|
|
53
|
+
policies = [BuiltInPolicies::AlwaysDeny]
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Ensure policy chain ends with AlwaysDeny (fail-secure)
|
|
57
|
+
unless policies.last == BuiltInPolicies::AlwaysDeny
|
|
58
|
+
logger&.warn { 'Policy chain should end with AlwaysDeny. Appending it.' }
|
|
59
|
+
policies = policies.dup << BuiltInPolicies::AlwaysDeny
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Evaluate policies in order
|
|
63
|
+
policies.each do |uncasted_policy|
|
|
64
|
+
result = policy_result(uncasted_policy, subject, actor, viewer_context, direct_object)
|
|
65
|
+
return true if result == :allow
|
|
66
|
+
return false if result == :deny
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
false
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Compute cache key based on policy arity
|
|
73
|
+
sig do
|
|
74
|
+
params(
|
|
75
|
+
policy: Policy,
|
|
76
|
+
subject: TPolicySubject,
|
|
77
|
+
actor: T.nilable(IActor),
|
|
78
|
+
viewer_context: ViewerContext,
|
|
79
|
+
direct_object: T.nilable(Sequel::Model)
|
|
80
|
+
).returns(Integer)
|
|
81
|
+
end
|
|
82
|
+
def self.compute_cache_key(policy, subject, actor, viewer_context, direct_object)
|
|
83
|
+
case policy.arity
|
|
84
|
+
when 0
|
|
85
|
+
[policy, viewer_context].hash
|
|
86
|
+
when 1
|
|
87
|
+
[policy, subject, viewer_context].hash
|
|
88
|
+
when 2
|
|
89
|
+
[policy, subject, actor, viewer_context].hash
|
|
90
|
+
else
|
|
91
|
+
[policy, subject, actor, direct_object, viewer_context].hash
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
sig { params(outcome: Symbol).returns(T::Boolean) }
|
|
96
|
+
def self.valid_outcome?(outcome)
|
|
97
|
+
%i[allow pass deny].include?(outcome)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Evaluate a combinator (array of policies returned by `all()`)
|
|
101
|
+
# All must allow for the result to be :allow, any :deny results in :deny
|
|
102
|
+
sig do
|
|
103
|
+
params(
|
|
104
|
+
child_policies: TPolicyArray,
|
|
105
|
+
subject: TPolicySubject,
|
|
106
|
+
actor: T.nilable(IActor),
|
|
107
|
+
viewer_context: ViewerContext,
|
|
108
|
+
direct_object: T.nilable(Sequel::Model)
|
|
109
|
+
).returns(Symbol)
|
|
110
|
+
end
|
|
111
|
+
def self.evaluate_child_policies(child_policies, subject, actor, viewer_context, direct_object)
|
|
112
|
+
unless child_policies.all? { |c| c.is_a?(Proc) }
|
|
113
|
+
Kernel.raise "Policy combinator contains non-policy members"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
results = child_policies.map do |child_policy|
|
|
117
|
+
policy_result(child_policy, subject, actor, viewer_context, direct_object)
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
return :deny if results.include?(:deny)
|
|
121
|
+
return :allow if results.all? { |r| r == :allow }
|
|
122
|
+
|
|
123
|
+
:pass
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Evaluate a single policy and return its result
|
|
127
|
+
sig do
|
|
128
|
+
params(
|
|
129
|
+
uncasted_policy: T.any(TPolicy, Proc),
|
|
130
|
+
subject: TPolicySubject,
|
|
131
|
+
actor: T.nilable(IActor),
|
|
132
|
+
viewer_context: ViewerContext,
|
|
133
|
+
direct_object: T.nilable(Sequel::Model)
|
|
134
|
+
).returns(Symbol)
|
|
135
|
+
end
|
|
136
|
+
def self.policy_result(uncasted_policy, subject, actor, viewer_context, direct_object)
|
|
137
|
+
from_cache = false
|
|
138
|
+
skipped_from_single_match = false
|
|
139
|
+
|
|
140
|
+
policy = T.cast(uncasted_policy, TPolicy, checked: false)
|
|
141
|
+
|
|
142
|
+
# Single-match optimization
|
|
143
|
+
if policy.single_match?
|
|
144
|
+
match_key = [policy, actor, viewer_context].hash
|
|
145
|
+
if (matched = Sequel::Privacy.single_matches[match_key]) && matched != subject.hash
|
|
146
|
+
skipped_from_single_match = true
|
|
147
|
+
result = :pass
|
|
148
|
+
end
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# Check cache
|
|
152
|
+
cache_key = compute_cache_key(policy, subject, actor, viewer_context, direct_object)
|
|
153
|
+
if !skipped_from_single_match && policy.cacheable? && Sequel::Privacy.cache.key?(cache_key)
|
|
154
|
+
from_cache = true
|
|
155
|
+
result = Sequel::Privacy.cache[cache_key]
|
|
156
|
+
Kernel.raise InvalidPolicyOutcomeError unless result && valid_outcome?(result)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Execute policy if not cached
|
|
160
|
+
result ||= execute_policy(policy, subject, actor, direct_object)
|
|
161
|
+
result ||= :pass
|
|
162
|
+
|
|
163
|
+
# Handle combinator results
|
|
164
|
+
if result.is_a?(Array)
|
|
165
|
+
result = evaluate_child_policies(result, subject, actor, viewer_context, direct_object)
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
# Cache result
|
|
169
|
+
if policy.cacheable? && !from_cache
|
|
170
|
+
Sequel::Privacy.cache[cache_key] = result
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Log result
|
|
174
|
+
log_result(policy, result, actor, subject, from_cache, skipped_from_single_match)
|
|
175
|
+
|
|
176
|
+
# Record single-match
|
|
177
|
+
if policy.single_match? && result == :allow
|
|
178
|
+
Sequel::Privacy.single_matches[[policy, actor, viewer_context].hash] = subject.hash
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
unless valid_outcome?(result)
|
|
182
|
+
Kernel.raise InvalidPolicyOutcomeError, "Policy returned #{result.inspect}, expected :allow, :deny, or :pass"
|
|
183
|
+
end
|
|
184
|
+
|
|
185
|
+
result
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
sig do
|
|
189
|
+
params(
|
|
190
|
+
policy: Policy,
|
|
191
|
+
subject: TPolicySubject,
|
|
192
|
+
actor: T.nilable(IActor),
|
|
193
|
+
direct_object: T.nilable(Sequel::Model)
|
|
194
|
+
).returns(T.untyped)
|
|
195
|
+
end
|
|
196
|
+
def self.execute_policy(policy, subject, actor, direct_object)
|
|
197
|
+
# 2+ arity policies require actor - auto-deny for anonymous
|
|
198
|
+
if !actor && policy.arity >= 2
|
|
199
|
+
return :deny
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
case policy.arity
|
|
203
|
+
when 0
|
|
204
|
+
Actions.instance_exec(&policy)
|
|
205
|
+
when 1
|
|
206
|
+
Actions.instance_exec(subject, &policy)
|
|
207
|
+
when 2
|
|
208
|
+
Actions.instance_exec(subject, T.must(actor), &policy)
|
|
209
|
+
else
|
|
210
|
+
Actions.instance_exec(subject, T.must(actor), direct_object, &policy)
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
sig do
|
|
215
|
+
params(
|
|
216
|
+
policy: Policy,
|
|
217
|
+
result: Symbol,
|
|
218
|
+
actor: T.nilable(IActor),
|
|
219
|
+
subject: TPolicySubject,
|
|
220
|
+
from_cache: T::Boolean,
|
|
221
|
+
skipped: T::Boolean
|
|
222
|
+
).void
|
|
223
|
+
end
|
|
224
|
+
def self.log_result(policy, result, actor, subject, from_cache, skipped)
|
|
225
|
+
return unless logger
|
|
226
|
+
|
|
227
|
+
actor_id = actor ? actor.id : 'anonymous'
|
|
228
|
+
logger.debug do
|
|
229
|
+
msg = "#{result.to_s.upcase}: #{policy.policy_name || 'anonymous'} for actor[#{actor_id}] on #{subject.class}[#{subject_id(subject)}]"
|
|
230
|
+
msg += " (cached)" if from_cache
|
|
231
|
+
msg += " (skipped: single_match)" if skipped
|
|
232
|
+
msg
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
if policy.comment && %i[deny allow].include?(result)
|
|
236
|
+
logger.debug { " ⮑ #{policy.comment}" }
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
sig { params(subject: TPolicySubject).returns(T.untyped) }
|
|
241
|
+
def self.subject_id(subject)
|
|
242
|
+
subject.respond_to?(:pk) ? subject.pk : subject.object_id
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Sequel
|
|
5
|
+
module Privacy
|
|
6
|
+
# Raised when a viewer is not authorized to perform an action (view, edit, create)
|
|
7
|
+
class Unauthorized < StandardError; end
|
|
8
|
+
|
|
9
|
+
# Raised when a viewer is not authorized to access or modify a specific field
|
|
10
|
+
class FieldUnauthorized < StandardError; end
|
|
11
|
+
|
|
12
|
+
# Raised when a policy returns an invalid outcome
|
|
13
|
+
class InvalidPolicyOutcomeError < StandardError; end
|
|
14
|
+
|
|
15
|
+
# Raised when an invalid viewer context is used
|
|
16
|
+
class InvalidViewerContextError < StandardError; end
|
|
17
|
+
|
|
18
|
+
class MissingViewerContext < StandardError; end
|
|
19
|
+
|
|
20
|
+
# Raised when attempting to modify privacy settings after finalization
|
|
21
|
+
class PrivacyAlreadyFinalizedError < StandardError; end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Sequel
|
|
5
|
+
module Privacy
|
|
6
|
+
# Interface that actors (typically User/Member models) must implement
|
|
7
|
+
# to be used with the privacy system.
|
|
8
|
+
module IActor
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend T::Helpers
|
|
11
|
+
interface!
|
|
12
|
+
|
|
13
|
+
sig { abstract.returns(Integer) }
|
|
14
|
+
def id; end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# typed: true
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Sequel
|
|
5
|
+
module Privacy
|
|
6
|
+
# A Policy wraps a Proc/lambda with metadata about how it should be evaluated.
|
|
7
|
+
#
|
|
8
|
+
# Policies take 0-3 arguments depending on what context they need:
|
|
9
|
+
# - 0 args: -> { allow } # Global decision
|
|
10
|
+
# - 1 arg: ->(actor) { allow if actor.is_role?(:admin) }
|
|
11
|
+
# - 2 args: ->(subject, actor) { allow if subject.owner_id == actor.id }
|
|
12
|
+
# - 3 args: ->(subject, actor, direct_object) { ... }
|
|
13
|
+
#
|
|
14
|
+
# Policies must return :allow, :deny, :pass, or an array of policies (for combinators).
|
|
15
|
+
class Policy < Proc
|
|
16
|
+
extend T::Sig
|
|
17
|
+
|
|
18
|
+
sig { returns(T.nilable(String)) }
|
|
19
|
+
attr_reader :policy_name
|
|
20
|
+
|
|
21
|
+
sig { returns(T.nilable(String)) }
|
|
22
|
+
attr_reader :comment
|
|
23
|
+
|
|
24
|
+
# Factory method for creating policies
|
|
25
|
+
sig do
|
|
26
|
+
params(
|
|
27
|
+
policy_name: Symbol,
|
|
28
|
+
lam: T.proc.returns(Symbol),
|
|
29
|
+
comment: T.nilable(String),
|
|
30
|
+
cacheable: T::Boolean,
|
|
31
|
+
single_match: T::Boolean
|
|
32
|
+
).returns(T.self_type)
|
|
33
|
+
end
|
|
34
|
+
def self.create(policy_name, lam, comment = nil, cacheable: true, single_match: false)
|
|
35
|
+
new(&lam).setup(
|
|
36
|
+
policy_name: policy_name,
|
|
37
|
+
comment: comment,
|
|
38
|
+
cacheable: cacheable,
|
|
39
|
+
single_match: single_match
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# Configure the policy after creation
|
|
44
|
+
#
|
|
45
|
+
# @param policy_name [Symbol, nil] Human-readable name for logging
|
|
46
|
+
# @param comment [String, nil] Description of what this policy does
|
|
47
|
+
# @param cacheable [Boolean] Whether results can be cached (default: true)
|
|
48
|
+
# @param single_match [Boolean] Whether only one subject/actor pair can match (default: false)
|
|
49
|
+
def setup(policy_name: nil, comment: nil, cacheable: true, single_match: false)
|
|
50
|
+
raise 'Privacy Policy is frozen' if @frozen
|
|
51
|
+
|
|
52
|
+
@cacheable = cacheable
|
|
53
|
+
@policy_name = policy_name.to_s
|
|
54
|
+
@comment = comment
|
|
55
|
+
@frozen = true
|
|
56
|
+
@single_match = single_match
|
|
57
|
+
self
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
sig { returns(T::Boolean) }
|
|
61
|
+
def cacheable?
|
|
62
|
+
@cacheable || false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Single-match optimization: when true, once a policy allows for a subject/actor pair,
|
|
66
|
+
# skip evaluation for other subjects (e.g., AllowIfActorIsSelf - only one subject matches)
|
|
67
|
+
sig { returns(T::Boolean) }
|
|
68
|
+
def single_match?
|
|
69
|
+
@single_match || false
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Type aliases for use throughout the gem
|
|
76
|
+
module Sequel
|
|
77
|
+
module Privacy
|
|
78
|
+
TPolicy = T.type_alias { Sequel::Privacy::Policy }
|
|
79
|
+
TPolicyArray = T.type_alias { T::Array[T.any(TPolicy, Proc)] }
|
|
80
|
+
TPolicySubject = T.type_alias { T.any(Sequel::Model, T.untyped) }
|
|
81
|
+
end
|
|
82
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# typed: false
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Sequel
|
|
5
|
+
module Privacy
|
|
6
|
+
# DSL for defining custom policies.
|
|
7
|
+
# Extend your policy module with this to get the `policy` method.
|
|
8
|
+
#
|
|
9
|
+
# Example:
|
|
10
|
+
# module P
|
|
11
|
+
# extend Sequel::Privacy::PolicyDSL
|
|
12
|
+
#
|
|
13
|
+
# AlwaysDeny = Sequel::Privacy::BuiltInPolicies::AlwaysDeny
|
|
14
|
+
#
|
|
15
|
+
# policy :AllowAdmins, ->(actor) {
|
|
16
|
+
# allow if actor.is_role?(:admin)
|
|
17
|
+
# }, 'Allow admin users', cacheable: true
|
|
18
|
+
# end
|
|
19
|
+
module PolicyDSL
|
|
20
|
+
# Define a new policy constant on the extending module.
|
|
21
|
+
#
|
|
22
|
+
# @param name [Symbol] The policy name (will become a constant)
|
|
23
|
+
# @param lam [Proc] The policy lambda (0-3 args: actor, subject, direct_object)
|
|
24
|
+
# @param comment [String, nil] Human-readable description
|
|
25
|
+
# @param cacheable [Boolean] Whether results can be cached (default: true)
|
|
26
|
+
# @param single_match [Boolean] Whether only one subject/actor can match (default: false)
|
|
27
|
+
def policy(name, lam, comment = nil, cacheable: true, single_match: false)
|
|
28
|
+
p = Policy.new(&lam).setup(
|
|
29
|
+
policy_name: name,
|
|
30
|
+
comment: comment,
|
|
31
|
+
cacheable: cacheable,
|
|
32
|
+
single_match: single_match
|
|
33
|
+
)
|
|
34
|
+
const_set(name, p)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Sequel
|
|
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.
|
|
8
|
+
class ViewerContext
|
|
9
|
+
extend T::Sig
|
|
10
|
+
extend T::Helpers
|
|
11
|
+
abstract!
|
|
12
|
+
|
|
13
|
+
# Create a standard viewer context for an actor
|
|
14
|
+
sig { params(actor: IActor).returns(ActorVC) }
|
|
15
|
+
def self.for_actor(actor)
|
|
16
|
+
ActorVC.new(actor)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Create an API-specific viewer context
|
|
20
|
+
sig { params(actor: IActor).returns(APIVC) }
|
|
21
|
+
def self.for_api_actor(actor)
|
|
22
|
+
APIVC.new(actor)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Create an all-powerful viewer context that bypasses all privacy checks.
|
|
26
|
+
# Use sparingly and always provide a reason for audit logging.
|
|
27
|
+
sig { params(reason: Symbol).returns(AllPowerfulVC) }
|
|
28
|
+
def self.all_powerful(reason)
|
|
29
|
+
Sequel::Privacy.logger&.info("Creating all-powerful viewer context: #{reason}")
|
|
30
|
+
AllPowerfulVC.new(reason)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Create an omniscient viewer context that can see everything but cannot mutate.
|
|
34
|
+
# Used for system operations like authentication lookups.
|
|
35
|
+
sig { params(reason: Symbol).returns(OmniscientVC) }
|
|
36
|
+
def self.omniscient(reason)
|
|
37
|
+
Sequel::Privacy.logger&.debug("Creating omniscient viewer context: #{reason}")
|
|
38
|
+
OmniscientVC.new(reason)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Create an anonymous viewer context for logged-out users.
|
|
42
|
+
# Subject to normal policy evaluation with no actor.
|
|
43
|
+
sig { returns(AnonymousVC) }
|
|
44
|
+
def self.anonymous
|
|
45
|
+
AnonymousVC.new
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Standard viewer context with an actor (user/member)
|
|
50
|
+
class ActorVC < ViewerContext
|
|
51
|
+
extend T::Sig
|
|
52
|
+
|
|
53
|
+
sig { params(actor: IActor).void }
|
|
54
|
+
def initialize(actor)
|
|
55
|
+
@actor = T.let(actor, IActor)
|
|
56
|
+
super()
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
sig { returns(IActor) }
|
|
60
|
+
attr_reader :actor
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# API-specific viewer context (same as ActorVC but can be distinguished)
|
|
64
|
+
class APIVC < ActorVC; end
|
|
65
|
+
|
|
66
|
+
# All-powerful viewer context that bypasses all privacy checks.
|
|
67
|
+
# Used for admin operations, background jobs, etc.
|
|
68
|
+
# Requires a reason for audit logging.
|
|
69
|
+
class AllPowerfulVC < ViewerContext
|
|
70
|
+
extend T::Sig
|
|
71
|
+
|
|
72
|
+
sig { params(reason: Symbol).void }
|
|
73
|
+
def initialize(reason)
|
|
74
|
+
@reason = T.let(reason, Symbol)
|
|
75
|
+
super()
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
sig { returns(Symbol) }
|
|
79
|
+
attr_reader :reason
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Omniscient viewer context that can see everything but cannot mutate.
|
|
83
|
+
# Used for system operations like authentication lookups.
|
|
84
|
+
class OmniscientVC < ViewerContext
|
|
85
|
+
extend T::Sig
|
|
86
|
+
|
|
87
|
+
sig { params(reason: Symbol).void }
|
|
88
|
+
def initialize(reason)
|
|
89
|
+
@reason = T.let(reason, Symbol)
|
|
90
|
+
super()
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
sig { returns(Symbol) }
|
|
94
|
+
attr_reader :reason
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Anonymous viewer context for logged-out users.
|
|
98
|
+
# Has no actor - policies with arity >= 1 will auto-deny.
|
|
99
|
+
class AnonymousVC < ViewerContext
|
|
100
|
+
extend T::Sig
|
|
101
|
+
|
|
102
|
+
sig { void }
|
|
103
|
+
def initialize
|
|
104
|
+
super()
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Internal policy evaluation viewer context.
|
|
109
|
+
# Used internally during policy evaluation to allow raw association access
|
|
110
|
+
# without triggering recursive privacy checks. For example, when checking
|
|
111
|
+
# "is actor a member of this list?", we need to access list.members without
|
|
112
|
+
# filtering those members by their own :view policies.
|
|
113
|
+
#
|
|
114
|
+
# This class is internal to the privacy plugin and should not be used directly.
|
|
115
|
+
class InternalPolicyEvaluationVC < ViewerContext
|
|
116
|
+
extend T::Sig
|
|
117
|
+
|
|
118
|
+
sig { void }
|
|
119
|
+
def initialize
|
|
120
|
+
super()
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# Type alias for viewer contexts
|
|
125
|
+
TViewerContext = T.type_alias { ViewerContext }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# typed: strict
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'sequel'
|
|
5
|
+
require 'sorbet-runtime'
|
|
6
|
+
|
|
7
|
+
module Sequel
|
|
8
|
+
module Privacy
|
|
9
|
+
class << self
|
|
10
|
+
extend T::Sig
|
|
11
|
+
|
|
12
|
+
# Configurable logger for privacy enforcement.
|
|
13
|
+
# Set this to your application's logger (e.g., SemanticLogger).
|
|
14
|
+
sig { returns(T.untyped) }
|
|
15
|
+
attr_accessor :logger
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Core privacy infrastructure
|
|
21
|
+
require_relative 'sequel/privacy/version'
|
|
22
|
+
require_relative 'sequel/privacy/errors'
|
|
23
|
+
require_relative 'sequel/privacy/i_actor'
|
|
24
|
+
require_relative 'sequel/privacy/policy'
|
|
25
|
+
require_relative 'sequel/privacy/cache'
|
|
26
|
+
require_relative 'sequel/privacy/actions'
|
|
27
|
+
require_relative 'sequel/privacy/viewer_context'
|
|
28
|
+
require_relative 'sequel/privacy/enforcer'
|
|
29
|
+
require_relative 'sequel/privacy/built_in_policies'
|
|
30
|
+
require_relative 'sequel/privacy/policy_dsl'
|
|
31
|
+
|
|
32
|
+
# The plugin is auto-loaded by Sequel when you call `plugin :privacy`
|
|
33
|
+
# from lib/sequel/plugins/privacy.rb
|