parse-stack-next 5.6.0 → 5.7.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 +4 -4
- data/CHANGELOG.md +409 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +7 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +167 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +5 -4
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +1 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +11 -11
- data/lib/parse/embeddings/video_source.rb +7 -6
- data/lib/parse/embeddings/voyage.rb +59 -61
- data/lib/parse/embeddings.rb +28 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +97 -15
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +21 -23
- data/lib/parse/model/core/fetching.rb +2 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +2 -2
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +4 -5
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +132 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +66 -41
- data/lib/parse/vector_search.rb +14 -11
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +22 -4
- data/parse-stack-next.gemspec +6 -6
- metadata +10 -1
data/lib/parse/access.rb
ADDED
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "set"
|
|
5
|
+
require_relative "clp_scope"
|
|
6
|
+
|
|
7
|
+
module Parse
|
|
8
|
+
# Local, evidence-aware inspection of Parse Server object permissions.
|
|
9
|
+
#
|
|
10
|
+
# This is a policy preflight, not an authorization boundary: sessions can be
|
|
11
|
+
# revoked and Cloud Code or custom adapters can add rules the SDK cannot see.
|
|
12
|
+
# The actual Parse Server request remains authoritative. Boolean convenience
|
|
13
|
+
# predicates accept only `:allowed`; both `:denied` and `:unknown` fail closed.
|
|
14
|
+
module Access
|
|
15
|
+
OPERATIONS = %i[read write delete].freeze
|
|
16
|
+
CLP_OPERATIONS = { read: :get, write: :update, delete: :delete }.freeze
|
|
17
|
+
SUPPORTED_SYSTEM_CLASSES = %w[_User _Role].freeze
|
|
18
|
+
|
|
19
|
+
# An access answer together with the evidence behind it.
|
|
20
|
+
class Decision
|
|
21
|
+
attr_reader :status, :operation, :reasons, :details
|
|
22
|
+
|
|
23
|
+
def initialize(status:, operation:, reasons: [], details: {})
|
|
24
|
+
@status = status.to_sym
|
|
25
|
+
@operation = operation.to_sym
|
|
26
|
+
@reasons = Array(reasons).compact.map(&:to_sym).uniq.freeze
|
|
27
|
+
@details = details.dup.freeze
|
|
28
|
+
freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def allowed?
|
|
32
|
+
status == :allowed
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def denied?
|
|
36
|
+
status == :denied
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def unknown?
|
|
40
|
+
status == :unknown
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def to_h
|
|
44
|
+
{ status: status, operation: operation, reasons: reasons, details: details }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
LayerResult = Struct.new(:status, :via, :reason, :role_claims, keyword_init: true)
|
|
49
|
+
Attempt = Struct.new(:decision, :roles_could_help, keyword_init: true)
|
|
50
|
+
private_constant :LayerResult, :Attempt
|
|
51
|
+
|
|
52
|
+
class << self
|
|
53
|
+
# Inspect one operation. `authenticated: true` is an assertion by the
|
|
54
|
+
# caller; it does not validate a session token. When omitted for a user,
|
|
55
|
+
# an attached session token is accepted as local evidence. An id-only
|
|
56
|
+
# user is not treated as authenticated. A role represents a hypothetical
|
|
57
|
+
# authenticated member, but cannot satisfy user/pointer-specific rules.
|
|
58
|
+
#
|
|
59
|
+
# @param principal [Parse::User, Parse::Role]
|
|
60
|
+
# @param object [Parse::Object]
|
|
61
|
+
# @param operation [Symbol] `:read`, `:write`, or `:delete`.
|
|
62
|
+
# @param client [Parse::Client, nil] application whose CLP and role graph
|
|
63
|
+
# should be inspected. Defaults to the principal's configured client.
|
|
64
|
+
# @param authenticated [Boolean, nil] explicit authentication evidence.
|
|
65
|
+
# @param max_role_depth [Integer] maximum inherited-role traversal depth.
|
|
66
|
+
# @return [Decision]
|
|
67
|
+
def check(principal:, object:, operation:, client: nil, authenticated: nil,
|
|
68
|
+
max_role_depth: 10)
|
|
69
|
+
Checker.new(
|
|
70
|
+
principal: principal,
|
|
71
|
+
object: object,
|
|
72
|
+
client: client,
|
|
73
|
+
authenticated: authenticated,
|
|
74
|
+
max_role_depth: max_role_depth,
|
|
75
|
+
).check(operation)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Inspect multiple operations while sharing one role-closure lookup.
|
|
79
|
+
# @return [Hash<Symbol, Decision>]
|
|
80
|
+
def check_all(principal:, object:, operations: OPERATIONS, client: nil,
|
|
81
|
+
authenticated: nil, max_role_depth: 10)
|
|
82
|
+
checker = Checker.new(
|
|
83
|
+
principal: principal,
|
|
84
|
+
object: object,
|
|
85
|
+
client: client,
|
|
86
|
+
authenticated: authenticated,
|
|
87
|
+
max_role_depth: max_role_depth,
|
|
88
|
+
)
|
|
89
|
+
Array(operations).each_with_object({}) do |operation, decisions|
|
|
90
|
+
op = operation.to_sym
|
|
91
|
+
decisions[op] = checker.check(op)
|
|
92
|
+
end.freeze
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Stateful only for the lifetime of one check/check_all call so an inherited
|
|
97
|
+
# role closure can be reused without becoming a stale authorization cache.
|
|
98
|
+
class Checker
|
|
99
|
+
def initialize(principal:, object:, client:, authenticated:, max_role_depth:)
|
|
100
|
+
@principal = principal
|
|
101
|
+
@object = object
|
|
102
|
+
@client = client || principal_client
|
|
103
|
+
@authentication = authentication_state(authenticated)
|
|
104
|
+
@max_role_depth = Integer(max_role_depth)
|
|
105
|
+
@expanded_claims = nil
|
|
106
|
+
@role_lookup_error = nil
|
|
107
|
+
rescue ArgumentError, TypeError
|
|
108
|
+
@max_role_depth = 0
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def check(operation)
|
|
112
|
+
op = operation.to_sym
|
|
113
|
+
raise ArgumentError, "unsupported access operation: #{operation.inspect}" unless
|
|
114
|
+
OPERATIONS.include?(op)
|
|
115
|
+
|
|
116
|
+
preflight = preflight_decision(op)
|
|
117
|
+
return preflight if preflight
|
|
118
|
+
|
|
119
|
+
case @authentication
|
|
120
|
+
when :unknown
|
|
121
|
+
check_with_unknown_authentication(op)
|
|
122
|
+
when :authenticated
|
|
123
|
+
check_with_authenticated_principal(op)
|
|
124
|
+
else
|
|
125
|
+
evaluate(op, public_claims, authenticated: false).decision
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
def preflight_decision(operation)
|
|
132
|
+
unless valid_principal?
|
|
133
|
+
return decision(:unknown, operation, :unsupported_principal)
|
|
134
|
+
end
|
|
135
|
+
unless defined?(Parse::Object) && @object.is_a?(Parse::Object)
|
|
136
|
+
return decision(:unknown, operation, :invalid_target)
|
|
137
|
+
end
|
|
138
|
+
return decision(:unknown, operation, :client_required) if @client.nil?
|
|
139
|
+
if @object.respond_to?(:has_selective_keys?) && @object.has_selective_keys?
|
|
140
|
+
return decision(:unknown, operation, :target_partially_fetched)
|
|
141
|
+
end
|
|
142
|
+
return decision(:unknown, operation, :target_is_pointer) if @object.pointer?
|
|
143
|
+
|
|
144
|
+
class_name = @object.parse_class.to_s
|
|
145
|
+
if class_name.start_with?("_") && !SUPPORTED_SYSTEM_CLASSES.include?(class_name)
|
|
146
|
+
return decision(:unknown, operation, :unsupported_system_class)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
user_target_preflight(operation, class_name)
|
|
150
|
+
rescue StandardError
|
|
151
|
+
decision(:unknown, operation, :target_state_unavailable)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def user_target_preflight(operation, class_name)
|
|
155
|
+
return unless class_name == Parse::Model::CLASS_USER
|
|
156
|
+
return if operation == :read
|
|
157
|
+
|
|
158
|
+
# Parse Server only permits a user to update/delete their own `_User`
|
|
159
|
+
# row. A role cannot identify which member is making the request.
|
|
160
|
+
return decision(:unknown, operation, :user_self_requires_concrete_member) if role_principal?
|
|
161
|
+
return decision(:denied, operation, :authentication_required) if @authentication == :anonymous
|
|
162
|
+
return decision(:unknown, operation, :authentication_unverified) if @authentication == :unknown
|
|
163
|
+
|
|
164
|
+
principal_id = safe_user_id
|
|
165
|
+
target_id = @object.id.to_s
|
|
166
|
+
if principal_id.empty? || target_id.empty?
|
|
167
|
+
return decision(:unknown, operation, :user_identity_unavailable)
|
|
168
|
+
end
|
|
169
|
+
return decision(:denied, operation, :user_self_only) unless principal_id == target_id
|
|
170
|
+
|
|
171
|
+
nil
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def check_with_unknown_authentication(operation)
|
|
175
|
+
anonymous = evaluate(operation, public_claims, authenticated: false)
|
|
176
|
+
return anonymous.decision if anonymous.decision.allowed?
|
|
177
|
+
|
|
178
|
+
# Probe only the direct identity claim. Never traverse a role graph for
|
|
179
|
+
# an id-only User: without a session that would let callers enumerate
|
|
180
|
+
# another user's effective permissions by assigning a victim objectId.
|
|
181
|
+
asserted = evaluate(operation, direct_claims, authenticated: true)
|
|
182
|
+
if asserted.decision.denied? && !asserted.roles_could_help
|
|
183
|
+
asserted.decision
|
|
184
|
+
else
|
|
185
|
+
decision(:unknown, operation, :authentication_unverified)
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def check_with_authenticated_principal(operation)
|
|
190
|
+
direct = evaluate(operation, direct_claims, authenticated: true)
|
|
191
|
+
return direct.decision if direct.decision.allowed?
|
|
192
|
+
return direct.decision unless direct.roles_could_help
|
|
193
|
+
return decision(:unknown, operation, :invalid_role_depth) if @max_role_depth <= 0
|
|
194
|
+
unless role_identity_available?
|
|
195
|
+
return decision(:unknown, operation, :role_identity_unavailable)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
claims = expanded_claims
|
|
199
|
+
if @role_lookup_error
|
|
200
|
+
return decision(
|
|
201
|
+
:unknown,
|
|
202
|
+
operation,
|
|
203
|
+
:role_membership_unavailable,
|
|
204
|
+
role_error: @role_lookup_error.class.name,
|
|
205
|
+
)
|
|
206
|
+
end
|
|
207
|
+
evaluate(operation, claims, authenticated: true).decision
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def evaluate(operation, claims, authenticated:)
|
|
211
|
+
acl = acl_evaluation(operation, claims, authenticated: authenticated)
|
|
212
|
+
clp = Parse::CLPScope.evaluate_access(
|
|
213
|
+
@object.parse_class,
|
|
214
|
+
CLP_OPERATIONS.fetch(operation),
|
|
215
|
+
claims: claims,
|
|
216
|
+
authenticated: authenticated,
|
|
217
|
+
user_id: concrete_user_id(authenticated),
|
|
218
|
+
client: @client,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
row_status = :allowed
|
|
222
|
+
row_reason = nil
|
|
223
|
+
if clp.row_check_required?
|
|
224
|
+
if clp.pointer_fields.any? { |field| !pointer_field_supported?(field) }
|
|
225
|
+
row_status = :unknown
|
|
226
|
+
row_reason = :pointer_field_schema_unavailable
|
|
227
|
+
elsif pointer_field_locally_changed?(clp.pointer_fields)
|
|
228
|
+
row_status = :unknown
|
|
229
|
+
row_reason = :pointer_field_has_local_changes
|
|
230
|
+
elsif pointer_fields_match?(clp.pointer_fields, concrete_user_id(authenticated))
|
|
231
|
+
row_status = :allowed
|
|
232
|
+
else
|
|
233
|
+
row_status = :denied
|
|
234
|
+
row_reason = :pointer_field_mismatch
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
statuses = [acl.status, clp.status, row_status]
|
|
239
|
+
status = if statuses.include?(:denied)
|
|
240
|
+
:denied
|
|
241
|
+
elsif statuses.include?(:unknown)
|
|
242
|
+
:unknown
|
|
243
|
+
else
|
|
244
|
+
:allowed
|
|
245
|
+
end
|
|
246
|
+
reasons = [acl.reason, clp.reason, row_reason]
|
|
247
|
+
details = { acl_via: acl.via, clp_via: clp.via }.compact
|
|
248
|
+
|
|
249
|
+
# Parent/user roles can recover a failed layer only when every other
|
|
250
|
+
# AND-ed layer is either already allowed or has its own role branch.
|
|
251
|
+
acl_recoverable = acl.status == :allowed || acl.role_claims.any?
|
|
252
|
+
clp_recoverable = clp.allowed? || clp.role_claims.any?
|
|
253
|
+
role_branch_exists = acl.role_claims.any? || clp.role_claims.any?
|
|
254
|
+
roles_could_help = status != :allowed && role_branch_exists &&
|
|
255
|
+
acl_recoverable && clp_recoverable
|
|
256
|
+
|
|
257
|
+
result = decision(status, operation, reasons, details)
|
|
258
|
+
if role_principal? && @object.parse_class == Parse::Model::CLASS_USER &&
|
|
259
|
+
operation == :read && !result.allowed?
|
|
260
|
+
result = decision(:unknown, operation, :user_self_requires_concrete_member)
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
Attempt.new(decision: result, roles_could_help: roles_could_help)
|
|
264
|
+
rescue StandardError => e
|
|
265
|
+
Attempt.new(
|
|
266
|
+
decision: decision(:unknown, operation, :permission_evaluation_failed,
|
|
267
|
+
error: e.class.name),
|
|
268
|
+
roles_could_help: false,
|
|
269
|
+
)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def acl_evaluation(operation, claims, authenticated:)
|
|
273
|
+
if user_self?(authenticated)
|
|
274
|
+
return layer(:allowed, via: :user_self)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
state = if @object.respond_to?(:authorization_acl_state)
|
|
278
|
+
@object.authorization_acl_state
|
|
279
|
+
else
|
|
280
|
+
:unknown
|
|
281
|
+
end
|
|
282
|
+
return layer(:unknown, reason: :acl_unavailable) if state == :unknown
|
|
283
|
+
|
|
284
|
+
if @object.id.to_s.length.positive? && @object.respond_to?(:acl_changed?) &&
|
|
285
|
+
@object.acl_changed?
|
|
286
|
+
return layer(:unknown, reason: :acl_has_local_changes)
|
|
287
|
+
end
|
|
288
|
+
return layer(:allowed, via: :public_default) if state == :absent
|
|
289
|
+
|
|
290
|
+
acl = @object.acl
|
|
291
|
+
return layer(:unknown, reason: :acl_unavailable) if acl.nil?
|
|
292
|
+
keys = if operation == :read
|
|
293
|
+
acl.readable_by
|
|
294
|
+
else
|
|
295
|
+
acl.writable_by
|
|
296
|
+
end
|
|
297
|
+
keys = Array(keys).map(&:to_s)
|
|
298
|
+
roles = keys.select { |key| key.start_with?("role:") }
|
|
299
|
+
if keys.any? { |key| claims.include?(key) }
|
|
300
|
+
via = keys.include?(Parse::ACL::PUBLIC) ? :public : :principal
|
|
301
|
+
layer(:allowed, via: via, role_claims: roles)
|
|
302
|
+
else
|
|
303
|
+
layer(:denied, reason: :acl_denied, role_claims: roles)
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def user_self?(authenticated)
|
|
308
|
+
return false unless authenticated && user_principal?
|
|
309
|
+
return false unless @object.parse_class == Parse::Model::CLASS_USER
|
|
310
|
+
principal_id = safe_user_id
|
|
311
|
+
!principal_id.empty? && principal_id == @object.id.to_s
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
def pointer_fields_match?(fields, user_id)
|
|
315
|
+
return false if user_id.to_s.empty?
|
|
316
|
+
return true if fields.any? { |field| local_pointer_value_matches?(field, user_id) }
|
|
317
|
+
document = @object.as_json(only_fetched: false)
|
|
318
|
+
Parse::CLPScope.filter_by_pointer_fields([document], fields, user_id).any?
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def pointer_field_supported?(field)
|
|
322
|
+
local, type = pointer_field_definition(field)
|
|
323
|
+
return false if local.nil?
|
|
324
|
+
return true if type == :array
|
|
325
|
+
return false unless type == :pointer
|
|
326
|
+
target = @object.class.references[local]
|
|
327
|
+
Parse::Model.same_parse_class?(target, Parse::Model::CLASS_USER)
|
|
328
|
+
rescue StandardError
|
|
329
|
+
false
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
def pointer_field_definition(field)
|
|
333
|
+
local = @object.field_map.find do |key, remote|
|
|
334
|
+
key.to_s == field.to_s || remote.to_s == field.to_s
|
|
335
|
+
end&.first
|
|
336
|
+
[local, local && @object.class.fields[local]]
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# Included pointers may be hydrated as full Parse::User objects. Their
|
|
340
|
+
# JSON representation has `__type: "Object"`, so the strict wire-format
|
|
341
|
+
# matcher correctly rejects it as a pointer; the typed local value still
|
|
342
|
+
# proves both the `_User` class and objectId without triggering a getter
|
|
343
|
+
# (and therefore without autofetching).
|
|
344
|
+
def local_pointer_value_matches?(field, user_id)
|
|
345
|
+
local, = pointer_field_definition(field)
|
|
346
|
+
return false if local.nil?
|
|
347
|
+
value = @object.instance_variable_get(:"@#{local}")
|
|
348
|
+
values = if value.respond_to?(:collection)
|
|
349
|
+
value.collection
|
|
350
|
+
elsif value.is_a?(Array)
|
|
351
|
+
value
|
|
352
|
+
else
|
|
353
|
+
[value]
|
|
354
|
+
end
|
|
355
|
+
values.any? do |candidate|
|
|
356
|
+
candidate.respond_to?(:parse_class) && candidate.respond_to?(:id) &&
|
|
357
|
+
Parse::Model.same_parse_class?(candidate.parse_class, Parse::Model::CLASS_USER) &&
|
|
358
|
+
candidate.id.to_s == user_id.to_s
|
|
359
|
+
end
|
|
360
|
+
rescue StandardError
|
|
361
|
+
false
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
def pointer_field_locally_changed?(fields)
|
|
365
|
+
return false if @object.id.to_s.empty?
|
|
366
|
+
changed = @object.respond_to?(:changed) ? @object.changed.map(&:to_s) : []
|
|
367
|
+
fields.any? do |field|
|
|
368
|
+
local = @object.field_map.find { |_key, remote| remote.to_s == field.to_s }&.first
|
|
369
|
+
changed.include?(field.to_s) || (local && changed.include?(local.to_s))
|
|
370
|
+
end
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def expanded_claims
|
|
374
|
+
return @expanded_claims if @expanded_claims
|
|
375
|
+
claims = direct_claims.dup
|
|
376
|
+
names = if user_principal?
|
|
377
|
+
Parse::Role.all_for_user(
|
|
378
|
+
@principal,
|
|
379
|
+
max_depth: @max_role_depth,
|
|
380
|
+
client: @client,
|
|
381
|
+
strict: true,
|
|
382
|
+
)
|
|
383
|
+
else
|
|
384
|
+
@principal.all_parent_role_names(
|
|
385
|
+
max_depth: @max_role_depth,
|
|
386
|
+
client: @client,
|
|
387
|
+
strict: true,
|
|
388
|
+
)
|
|
389
|
+
end
|
|
390
|
+
Array(names).each do |name|
|
|
391
|
+
next if name.to_s.empty?
|
|
392
|
+
claims << "role:#{name}"
|
|
393
|
+
end
|
|
394
|
+
@expanded_claims = claims.freeze
|
|
395
|
+
rescue StandardError => e
|
|
396
|
+
@role_lookup_error = e
|
|
397
|
+
@expanded_claims = direct_claims.freeze
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
def public_claims
|
|
401
|
+
Set.new([Parse::ACL::PUBLIC]).freeze
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def direct_claims
|
|
405
|
+
claims = Set.new([Parse::ACL::PUBLIC])
|
|
406
|
+
if user_principal?
|
|
407
|
+
user_id = safe_user_id
|
|
408
|
+
claims << user_id unless user_id.empty?
|
|
409
|
+
else
|
|
410
|
+
role_name = safe_role_name
|
|
411
|
+
claims << "role:#{role_name}" unless role_name.empty?
|
|
412
|
+
end
|
|
413
|
+
claims
|
|
414
|
+
end
|
|
415
|
+
|
|
416
|
+
def concrete_user_id(authenticated)
|
|
417
|
+
authenticated && user_principal? ? safe_user_id : nil
|
|
418
|
+
end
|
|
419
|
+
|
|
420
|
+
def authentication_state(explicit)
|
|
421
|
+
return :authenticated if explicit == true
|
|
422
|
+
return :anonymous if explicit == false
|
|
423
|
+
return :authenticated if role_principal?
|
|
424
|
+
return :unknown unless user_principal?
|
|
425
|
+
token = @principal.instance_variable_get(:@session_token)
|
|
426
|
+
token.is_a?(String) && !token.empty? ? :authenticated : :unknown
|
|
427
|
+
end
|
|
428
|
+
|
|
429
|
+
def valid_principal?
|
|
430
|
+
user_principal? || role_principal?
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
def user_principal?
|
|
434
|
+
defined?(Parse::User) && @principal.is_a?(Parse::User)
|
|
435
|
+
end
|
|
436
|
+
|
|
437
|
+
def role_principal?
|
|
438
|
+
defined?(Parse::Role) && @principal.is_a?(Parse::Role)
|
|
439
|
+
end
|
|
440
|
+
|
|
441
|
+
def safe_user_id
|
|
442
|
+
user_principal? ? @principal.id.to_s : ""
|
|
443
|
+
rescue StandardError
|
|
444
|
+
""
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# Reading Role#name can autofetch an id-only role. Permission inspection
|
|
448
|
+
# must stay local, so use only an already-hydrated value.
|
|
449
|
+
def safe_role_name
|
|
450
|
+
value = @principal.instance_variable_get(:@name) if role_principal?
|
|
451
|
+
value.to_s
|
|
452
|
+
rescue StandardError
|
|
453
|
+
""
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
def role_identity_available?
|
|
457
|
+
if user_principal?
|
|
458
|
+
!safe_user_id.empty?
|
|
459
|
+
else
|
|
460
|
+
!safe_role_name.empty? && !@principal.id.to_s.empty?
|
|
461
|
+
end
|
|
462
|
+
rescue StandardError
|
|
463
|
+
false
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
def principal_client
|
|
467
|
+
@principal.client if @principal.respond_to?(:client)
|
|
468
|
+
rescue StandardError
|
|
469
|
+
nil
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
def layer(status, via: nil, reason: nil, role_claims: [])
|
|
473
|
+
LayerResult.new(
|
|
474
|
+
status: status,
|
|
475
|
+
via: via,
|
|
476
|
+
reason: reason,
|
|
477
|
+
role_claims: Array(role_claims).freeze,
|
|
478
|
+
)
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
def decision(status, operation, reasons = [], details = {})
|
|
482
|
+
Decision.new(
|
|
483
|
+
status: status,
|
|
484
|
+
operation: operation,
|
|
485
|
+
reasons: reasons,
|
|
486
|
+
details: details,
|
|
487
|
+
)
|
|
488
|
+
end
|
|
489
|
+
end
|
|
490
|
+
|
|
491
|
+
private_constant :Checker
|
|
492
|
+
end
|
|
493
|
+
end
|