parse-stack-next 5.5.6 → 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 +634 -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 +17 -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/binding_audit.rb +211 -0
- 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 +137 -0
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +40 -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 +170 -0
- data/lib/parse/embeddings/video_source.rb +121 -0
- data/lib/parse/embeddings/voyage.rb +554 -143
- data/lib/parse/embeddings.rb +83 -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 +43 -19
- 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 +39 -3
- 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 +34 -8
- 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 +166 -33
- data/lib/parse/vector_search.rb +141 -13
- 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 +14 -1
|
@@ -62,6 +62,47 @@ module Parse
|
|
|
62
62
|
# @return [RelationCollectionProxy<User>] a Parse relation of users belonging to this role.
|
|
63
63
|
has_many :users, through: :relation
|
|
64
64
|
|
|
65
|
+
# Names of Mongo driver errors that mean "the server is momentarily
|
|
66
|
+
# unreachable", for which falling back to the Parse Server walk is right.
|
|
67
|
+
#
|
|
68
|
+
# Names rather than classes, resolved on first use rather than at load
|
|
69
|
+
# time, because the driver is required lazily (`Parse::MongoDB.require_gem!`)
|
|
70
|
+
# and `Mongo::Error` does not exist when this file loads.
|
|
71
|
+
#
|
|
72
|
+
# The previous check tested `Mongo::Error::ConnectionFailure`, which does
|
|
73
|
+
# NOT exist in the locked 2.25.0 driver, so `defined?` was always false and
|
|
74
|
+
# the fallback never fired on a real error. The tests manufactured the
|
|
75
|
+
# constant themselves, which is how a guard can be green and dead at the
|
|
76
|
+
# same time. Real availability errors propagated instead of degrading.
|
|
77
|
+
#
|
|
78
|
+
# `ConnectionFailure` is kept for older drivers that do define it.
|
|
79
|
+
# Deliberately narrow otherwise: `ExecutionTimeout`, `DeniedOperator`, and
|
|
80
|
+
# `CLPScope::Denied` are attack signals or policy denials and must keep
|
|
81
|
+
# propagating rather than silently downgrading to the slow path.
|
|
82
|
+
MONGO_AVAILABILITY_ERROR_NAMES = %w[
|
|
83
|
+
ConnectionFailure
|
|
84
|
+
ConnectionUnavailable
|
|
85
|
+
ConnectionPerished
|
|
86
|
+
ConnectionCheckOutTimeout
|
|
87
|
+
SocketError
|
|
88
|
+
SocketTimeoutError
|
|
89
|
+
NoServerAvailable
|
|
90
|
+
].freeze
|
|
91
|
+
|
|
92
|
+
# @return [Array<Class>] the subset of {MONGO_AVAILABILITY_ERROR_NAMES}
|
|
93
|
+
# this driver actually defines.
|
|
94
|
+
def self.mongo_availability_errors
|
|
95
|
+
return [] unless defined?(::Mongo::Error)
|
|
96
|
+
@mongo_availability_errors ||= MONGO_AVAILABILITY_ERROR_NAMES.filter_map do |name|
|
|
97
|
+
::Mongo::Error.const_get(name) if ::Mongo::Error.const_defined?(name)
|
|
98
|
+
end.freeze
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# @return [Boolean] whether `error` means the server was unreachable.
|
|
102
|
+
def self.mongo_availability_error?(error)
|
|
103
|
+
mongo_availability_errors.any? { |klass| error.is_a?(klass) }
|
|
104
|
+
end
|
|
105
|
+
|
|
65
106
|
# Parse Server requires every _Role row to ship with an ACL (the
|
|
66
107
|
# requirement is hard-coded in SchemaController.requiredColumns and
|
|
67
108
|
# cannot be disabled by config). We default to master-only (ACL = {})
|
|
@@ -150,6 +191,9 @@ module Parse
|
|
|
150
191
|
# scope (subject to `_Role` CLP). The scope is forwarded
|
|
151
192
|
# verbatim to {Parse::MongoDB.role_names_for_user}; CLP denial
|
|
152
193
|
# raises {Parse::CLPScope::Denied}.
|
|
194
|
+
# @param strict [Boolean] re-raise REST role-query failures instead of
|
|
195
|
+
# returning the closure resolved before the failure. Access inspection
|
|
196
|
+
# uses this to distinguish no membership from unavailable evidence.
|
|
153
197
|
# @return [Set<String>] role names (no `role:` prefix) the user
|
|
154
198
|
# transitively inherits permissions from, including direct
|
|
155
199
|
# memberships. Empty set for anonymous or no-membership users.
|
|
@@ -166,7 +210,8 @@ module Parse
|
|
|
166
210
|
# @example
|
|
167
211
|
# names = Parse::Role.all_for_user(user, master: true) # admin/analytics
|
|
168
212
|
# names = Parse::Role.all_for_user(user, as: current_user) # scope-checked
|
|
169
|
-
def all_for_user(user, max_depth: 10, master: false, as: nil
|
|
213
|
+
def all_for_user(user, max_depth: 10, master: false, as: nil, client: nil,
|
|
214
|
+
strict: false)
|
|
170
215
|
names = Set.new
|
|
171
216
|
return names if user.nil? || max_depth <= 0
|
|
172
217
|
|
|
@@ -182,7 +227,7 @@ module Parse
|
|
|
182
227
|
# have no scope to forward.
|
|
183
228
|
if master == true || !as.nil?
|
|
184
229
|
fast_path_result = all_for_user_mongo_fast_path(
|
|
185
|
-
user_pointer.id, max_depth, master: master, as: as,
|
|
230
|
+
user_pointer.id, max_depth, master: master, as: as, client: client,
|
|
186
231
|
)
|
|
187
232
|
if fast_path_result.is_a?(Set)
|
|
188
233
|
ActiveSupport::Notifications.instrument(
|
|
@@ -195,13 +240,34 @@ module Parse
|
|
|
195
240
|
end
|
|
196
241
|
end
|
|
197
242
|
|
|
243
|
+
if as
|
|
244
|
+
# FAIL CLOSED. The slow path below reads `_Role` through the REST
|
|
245
|
+
# client, which in a master-keyed process answers with every role
|
|
246
|
+
# regardless of what `as:` may see. Silently falling back would turn
|
|
247
|
+
# a scoped traversal into a master-keyed one and hand the caller a
|
|
248
|
+
# closure they were never entitled to. A caller who wants the master
|
|
249
|
+
# answer has to ask for it.
|
|
250
|
+
raise Parse::MongoDB::NotEnabled,
|
|
251
|
+
"Parse::Role.all_for_user: `as:` requires the mongo-direct role graph, " \
|
|
252
|
+
"which is unavailable. Refusing to fall back to the Parse Server walk, " \
|
|
253
|
+
"which would run under the client's own credentials rather than the " \
|
|
254
|
+
"requested scope. Configure Parse::MongoDB, or pass `master: true` to " \
|
|
255
|
+
"take the unscoped answer deliberately."
|
|
256
|
+
end
|
|
257
|
+
|
|
198
258
|
begin
|
|
199
|
-
direct_roles =
|
|
200
|
-
rescue
|
|
259
|
+
direct_roles = role_query_all({ users: user_pointer }, client: client)
|
|
260
|
+
rescue StandardError
|
|
261
|
+
raise if strict
|
|
201
262
|
return names
|
|
202
263
|
end
|
|
203
264
|
|
|
204
|
-
result = expand_inheritance_upward(
|
|
265
|
+
result = expand_inheritance_upward(
|
|
266
|
+
direct_roles,
|
|
267
|
+
max_depth: max_depth,
|
|
268
|
+
client: client,
|
|
269
|
+
strict: strict,
|
|
270
|
+
)
|
|
205
271
|
ActiveSupport::Notifications.instrument(
|
|
206
272
|
"parse.role.expand",
|
|
207
273
|
direction: :forward, target_id: user_pointer.id,
|
|
@@ -217,11 +283,11 @@ module Parse
|
|
|
217
283
|
# is unavailable (mongo not configured, or a benign availability
|
|
218
284
|
# error). Attack-signal errors (timeouts, denied operators,
|
|
219
285
|
# CLP::Denied, ArgumentError on missing auth) are propagated.
|
|
220
|
-
def all_for_user_mongo_fast_path(user_id, max_depth, master: false, as: nil)
|
|
286
|
+
def all_for_user_mongo_fast_path(user_id, max_depth, master: false, as: nil, client: nil)
|
|
221
287
|
return nil unless defined?(Parse::MongoDB)
|
|
222
288
|
return nil unless Parse::MongoDB.respond_to?(:role_names_for_user)
|
|
223
289
|
Parse::MongoDB.role_names_for_user(
|
|
224
|
-
user_id, max_depth: max_depth, master: master, as: as,
|
|
290
|
+
user_id, max_depth: max_depth, master: master, as: as, client: client,
|
|
225
291
|
)
|
|
226
292
|
rescue StandardError => e
|
|
227
293
|
# Fall back to Parse-Server path on benign availability errors
|
|
@@ -229,8 +295,7 @@ module Parse
|
|
|
229
295
|
# ExecutionTimeout, DeniedOperator, CLPScope::Denied,
|
|
230
296
|
# ArgumentError, and any unrecognized Mongo::Error subclass —
|
|
231
297
|
# so attack signals aren't masked by a silent slow-path retry.
|
|
232
|
-
if
|
|
233
|
-
e.is_a?(::Mongo::Error::ConnectionFailure)
|
|
298
|
+
if Parse::Role.mongo_availability_error?(e)
|
|
234
299
|
# Emit a structured event so operators can observe the
|
|
235
300
|
# fast-path-unavailable rate (e.g. analytics-replica
|
|
236
301
|
# connection flapping). The fallback to the Parse-Server
|
|
@@ -262,7 +327,46 @@ module Parse
|
|
|
262
327
|
# @param max_depth [Integer] maximum BFS depth.
|
|
263
328
|
# @return [Set<String>] role names (no `role:` prefix) including
|
|
264
329
|
# the starting frontier and every transitive parent.
|
|
265
|
-
|
|
330
|
+
# @!visibility private
|
|
331
|
+
# Run a `_Role` query against a SPECIFIC client.
|
|
332
|
+
#
|
|
333
|
+
# `Parse::Role.all(...)` resolves its client through the class, which is
|
|
334
|
+
# the default client. That was fine while identity resolution was also
|
|
335
|
+
# global, and became a split brain once it was not: a session token
|
|
336
|
+
# could be resolved against client B while the role closure for the
|
|
337
|
+
# resulting user was walked against the default application, mixing one
|
|
338
|
+
# application's identity with another's role graph.
|
|
339
|
+
#
|
|
340
|
+
# A nil client keeps the historical behavior.
|
|
341
|
+
# @!visibility private
|
|
342
|
+
# Run a `_User` query against a SPECIFIC client. Same rationale as
|
|
343
|
+
# {.role_query_all}: `Parse::User.all` resolves through the class, which
|
|
344
|
+
# is the default client, so the reverse role traversal would hydrate one
|
|
345
|
+
# application's users while the subtree came from another's database.
|
|
346
|
+
def user_query_all(constraints, client: nil)
|
|
347
|
+
return Parse::User.all(**constraints) if client.nil?
|
|
348
|
+
query = Parse::User.query(constraints)
|
|
349
|
+
query.client = client
|
|
350
|
+
query.results
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
def role_query_all(constraints, client: nil)
|
|
354
|
+
# No explicit client means the historical path, unchanged. This is not
|
|
355
|
+
# only for compatibility: `Parse::Role.all` is what callers and tests
|
|
356
|
+
# observe and stub, and routing around it when nothing asked us to
|
|
357
|
+
# would change behavior for every existing caller to fix a problem
|
|
358
|
+
# none of them have.
|
|
359
|
+
# Double-splat, not a positional Hash: `Parse::Role.all` takes
|
|
360
|
+
# keywords, and Ruby 3 does not convert one to the other.
|
|
361
|
+
return Parse::Role.all(**constraints) if client.nil?
|
|
362
|
+
|
|
363
|
+
query = Parse::Role.query(constraints)
|
|
364
|
+
query.client = client
|
|
365
|
+
query.results
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
def expand_inheritance_upward(starting_roles, max_depth: 10, client: nil,
|
|
369
|
+
strict: false)
|
|
266
370
|
names = Set.new
|
|
267
371
|
visited_ids = Set.new
|
|
268
372
|
frontier = []
|
|
@@ -281,8 +385,9 @@ module Parse
|
|
|
281
385
|
frontier.each do |role|
|
|
282
386
|
next if role.nil? || role.id.nil?
|
|
283
387
|
begin
|
|
284
|
-
parents =
|
|
285
|
-
rescue
|
|
388
|
+
parents = role_query_all({ roles: role }, client: client)
|
|
389
|
+
rescue StandardError
|
|
390
|
+
raise if strict
|
|
286
391
|
next
|
|
287
392
|
end
|
|
288
393
|
parents.each do |parent|
|
|
@@ -554,13 +659,13 @@ module Parse
|
|
|
554
659
|
# @example
|
|
555
660
|
# all_users = admin_role.all_users(master: true)
|
|
556
661
|
# visible = admin_role.all_users(as: current_user)
|
|
557
|
-
def all_users(max_depth: 10, visited: Set.new, master: false, as: nil)
|
|
662
|
+
def all_users(max_depth: 10, visited: Set.new, master: false, as: nil, client: nil)
|
|
558
663
|
return [] if max_depth <= 0
|
|
559
664
|
return [] if id.nil? || visited.include?(id)
|
|
560
665
|
|
|
561
666
|
# The fast path is opt-in (same rationale as {.all_for_user}).
|
|
562
667
|
if master == true || !as.nil?
|
|
563
|
-
fast_path = all_users_mongo_fast_path(max_depth, master: master, as: as)
|
|
668
|
+
fast_path = all_users_mongo_fast_path(max_depth, master: master, as: as, client: client)
|
|
564
669
|
if fast_path.is_a?(Array)
|
|
565
670
|
ActiveSupport::Notifications.instrument(
|
|
566
671
|
"parse.role.expand",
|
|
@@ -571,13 +676,32 @@ module Parse
|
|
|
571
676
|
end
|
|
572
677
|
end
|
|
573
678
|
|
|
574
|
-
|
|
679
|
+
if as
|
|
680
|
+
# Same fail-closed rule as {.all_for_user}: the relation reads below
|
|
681
|
+
# run under the client's own credentials, so a master-keyed process
|
|
682
|
+
# would answer a scoped request with every user in the subtree.
|
|
683
|
+
raise Parse::MongoDB::NotEnabled,
|
|
684
|
+
"Parse::Role#all_users: `as:` requires the mongo-direct role graph, " \
|
|
685
|
+
"which is unavailable. Refusing to fall back to the Parse Server walk, " \
|
|
686
|
+
"which would run under the client's own credentials rather than the " \
|
|
687
|
+
"requested scope. Configure Parse::MongoDB, or pass `master: true` to " \
|
|
688
|
+
"take the unscoped answer deliberately."
|
|
689
|
+
end
|
|
575
690
|
|
|
576
|
-
|
|
691
|
+
visited << id
|
|
577
692
|
|
|
578
|
-
|
|
693
|
+
# The posture travels with the recursion. Dropping `client:` here meant
|
|
694
|
+
# a traversal that started on client B finished on the default, and
|
|
695
|
+
# dropping `master:` meant an explicit master call could silently
|
|
696
|
+
# downgrade under client mode or an ambient session token partway down
|
|
697
|
+
# the tree.
|
|
698
|
+
direct_users = relation_all(users, client: client)
|
|
699
|
+
child_roles = relation_all(roles, client: client)
|
|
579
700
|
child_users = child_roles.flat_map do |child_role|
|
|
580
|
-
child_role.all_users(
|
|
701
|
+
child_role.all_users(
|
|
702
|
+
max_depth: max_depth - 1, visited: visited,
|
|
703
|
+
master: master, as: as, client: client,
|
|
704
|
+
)
|
|
581
705
|
end
|
|
582
706
|
|
|
583
707
|
result = (direct_users + child_users).uniq { |u| u.id }
|
|
@@ -601,28 +725,27 @@ module Parse
|
|
|
601
725
|
# (sub-pipeline `_rperm` match in the role-subtree join, see
|
|
602
726
|
# MONGO-4) AND on the hydration query (full _User row-level ACL
|
|
603
727
|
# filtering before the rows hit the wire).
|
|
604
|
-
def all_users_mongo_fast_path(max_depth, master: false, as: nil)
|
|
728
|
+
def all_users_mongo_fast_path(max_depth, master: false, as: nil, client: nil)
|
|
605
729
|
return nil unless defined?(Parse::MongoDB)
|
|
606
730
|
return nil unless Parse::MongoDB.respond_to?(:users_in_role_subtree)
|
|
607
731
|
ids = Parse::MongoDB.users_in_role_subtree(
|
|
608
|
-
id, max_depth: max_depth, master: master, as: as,
|
|
732
|
+
id, max_depth: max_depth, master: master, as: as, client: client,
|
|
609
733
|
)
|
|
610
734
|
return nil if ids.nil?
|
|
611
735
|
return [] if ids.empty?
|
|
612
736
|
|
|
613
737
|
if master == true
|
|
614
738
|
# Master path: master-keyed default client returns every row.
|
|
615
|
-
Parse::
|
|
739
|
+
Parse::Role.send(:user_query_all, { :objectId.in => ids.to_a }, client: client)
|
|
616
740
|
else
|
|
617
741
|
# Scoped path: route through Parse::MongoDB.aggregate so _User
|
|
618
742
|
# ACL is enforced by the SDK on the hydration query. The
|
|
619
743
|
# aggregate already strips protectedFields and filters by
|
|
620
744
|
# _rperm/CLP under the resolved scope.
|
|
621
|
-
hydrate_users_under_scope(ids.to_a, as)
|
|
745
|
+
hydrate_users_under_scope(ids.to_a, as, client: client)
|
|
622
746
|
end
|
|
623
747
|
rescue StandardError => e
|
|
624
|
-
if
|
|
625
|
-
e.is_a?(::Mongo::Error::ConnectionFailure)
|
|
748
|
+
if Parse::Role.mongo_availability_error?(e)
|
|
626
749
|
# Emit a structured event so operators can monitor fast-path
|
|
627
750
|
# availability separate from the role-graph notification.
|
|
628
751
|
ActiveSupport::Notifications.instrument(
|
|
@@ -636,6 +759,19 @@ module Parse
|
|
|
636
759
|
end
|
|
637
760
|
end
|
|
638
761
|
|
|
762
|
+
# @!visibility private
|
|
763
|
+
# Read a relation against a SPECIFIC client. `relation.all` resolves
|
|
764
|
+
# through the class, which is the default client, so a traversal that
|
|
765
|
+
# started on a secondary client would finish on the default one.
|
|
766
|
+
def relation_all(relation, client: nil)
|
|
767
|
+
return relation.all if client.nil?
|
|
768
|
+
query = relation.query
|
|
769
|
+
query.client = client
|
|
770
|
+
query.results
|
|
771
|
+
rescue StandardError
|
|
772
|
+
relation.all
|
|
773
|
+
end
|
|
774
|
+
|
|
639
775
|
# @!visibility private
|
|
640
776
|
# Hydrate a list of `_User.objectId`s into {Parse::User} instances
|
|
641
777
|
# via `Parse::MongoDB.aggregate` under the supplied scope. This is
|
|
@@ -644,14 +780,14 @@ module Parse
|
|
|
644
780
|
# instead of the master-keyed `Parse::User.all`.
|
|
645
781
|
#
|
|
646
782
|
# Returns an Array of {Parse::User} instances (possibly empty).
|
|
647
|
-
def hydrate_users_under_scope(ids, as_scope)
|
|
783
|
+
def hydrate_users_under_scope(ids, as_scope, client: nil)
|
|
648
784
|
return [] if ids.nil? || ids.empty?
|
|
649
785
|
pipeline = [
|
|
650
786
|
{ "$match" => { "_id" => { "$in" => ids.map(&:to_s) } } },
|
|
651
787
|
]
|
|
652
788
|
raw = Parse::MongoDB.aggregate(
|
|
653
789
|
Parse::Model::CLASS_USER, pipeline,
|
|
654
|
-
allow_internal_fields: true, acl_user: as_scope,
|
|
790
|
+
allow_internal_fields: true, acl_user: as_scope, client: client,
|
|
655
791
|
)
|
|
656
792
|
raw.map do |doc|
|
|
657
793
|
parse_doc = Parse::MongoDB.convert_document_to_parse(
|
|
@@ -660,8 +796,61 @@ module Parse
|
|
|
660
796
|
Parse::User.new(parse_doc) if parse_doc
|
|
661
797
|
end.compact
|
|
662
798
|
end
|
|
799
|
+
|
|
663
800
|
private :hydrate_users_under_scope
|
|
664
801
|
|
|
802
|
+
# Inspect one effective object permission for a hypothetical authenticated
|
|
803
|
+
# member of this role. User-specific, pointer-specific, and `_User` self
|
|
804
|
+
# rules remain unknown because a role does not identify a concrete member.
|
|
805
|
+
#
|
|
806
|
+
# @param object [Parse::Object] the Parse object to check.
|
|
807
|
+
# @param operation [Symbol] `:read`, `:write`, or `:delete`.
|
|
808
|
+
# @return [Parse::Access::Decision]
|
|
809
|
+
def access_decision(object, operation, client: nil, authenticated: nil,
|
|
810
|
+
max_role_depth: 10)
|
|
811
|
+
require_relative "../../access" unless defined?(Parse::Access)
|
|
812
|
+
Parse::Access.check(
|
|
813
|
+
principal: self,
|
|
814
|
+
object: object,
|
|
815
|
+
operation: operation,
|
|
816
|
+
client: client,
|
|
817
|
+
authenticated: authenticated,
|
|
818
|
+
max_role_depth: max_role_depth,
|
|
819
|
+
)
|
|
820
|
+
end
|
|
821
|
+
|
|
822
|
+
# Inspect read, write, and delete while sharing one parent-role lookup.
|
|
823
|
+
# @return [Hash<Symbol, Parse::Access::Decision>]
|
|
824
|
+
def access_decisions(object, client: nil, authenticated: nil, max_role_depth: 10)
|
|
825
|
+
require_relative "../../access" unless defined?(Parse::Access)
|
|
826
|
+
Parse::Access.check_all(
|
|
827
|
+
principal: self,
|
|
828
|
+
object: object,
|
|
829
|
+
client: client,
|
|
830
|
+
authenticated: authenticated,
|
|
831
|
+
max_role_depth: max_role_depth,
|
|
832
|
+
)
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# Return whether the role-derived policy definitively grants read access.
|
|
836
|
+
# Unknown states fail closed.
|
|
837
|
+
# @return [Boolean]
|
|
838
|
+
def can_read?(object, **options)
|
|
839
|
+
access_decision(object, :read, **options).allowed?
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
# Return whether the role-derived policy definitively grants update access.
|
|
843
|
+
# @return [Boolean]
|
|
844
|
+
def can_write?(object, **options)
|
|
845
|
+
access_decision(object, :write, **options).allowed?
|
|
846
|
+
end
|
|
847
|
+
|
|
848
|
+
# Return whether the role-derived policy definitively grants delete access.
|
|
849
|
+
# @return [Boolean]
|
|
850
|
+
def can_delete?(object, **options)
|
|
851
|
+
access_decision(object, :delete, **options).allowed?
|
|
852
|
+
end
|
|
853
|
+
|
|
665
854
|
# Get the set of role names whose presence in a `_rperm` array
|
|
666
855
|
# grants access to this role's members. That's the role itself
|
|
667
856
|
# plus every role `P` that lists this role in its `roles` relation,
|
|
@@ -688,8 +877,18 @@ module Parse
|
|
|
688
877
|
# `self.name` and every transitive parent.
|
|
689
878
|
# @example
|
|
690
879
|
# permission_strings = admin.all_parent_role_names.map { |n| "role:#{n}" }
|
|
691
|
-
|
|
692
|
-
|
|
880
|
+
# @param client [Parse::Client, nil] resolve the `_Role` traversal
|
|
881
|
+
# against this client. Nil uses the default, which is the historical
|
|
882
|
+
# behavior. Supplying it matters wherever the caller's identity was
|
|
883
|
+
# resolved against a specific client: walking the role graph on the
|
|
884
|
+
# default application would then mix one application's identity with
|
|
885
|
+
# another's role names.
|
|
886
|
+
# @param strict [Boolean] re-raise role-query failures rather than returning
|
|
887
|
+
# a partial parent closure.
|
|
888
|
+
def all_parent_role_names(max_depth: 10, client: nil, strict: false)
|
|
889
|
+
Parse::Role.expand_inheritance_upward(
|
|
890
|
+
[self], max_depth: max_depth, client: client, strict: strict,
|
|
891
|
+
)
|
|
693
892
|
end
|
|
694
893
|
|
|
695
894
|
# Get all child roles recursively. Cycle-safe; see {#all_users}.
|
|
@@ -450,7 +450,7 @@ module Parse
|
|
|
450
450
|
|
|
451
451
|
# @!visibility private
|
|
452
452
|
def _rebuild_user_protected_fields!
|
|
453
|
-
@master_only_fields
|
|
453
|
+
@master_only_fields ||= []
|
|
454
454
|
@self_visible_fields ||= []
|
|
455
455
|
pointer = @self_pointer_field || :self
|
|
456
456
|
all_hidden = (@master_only_fields + @self_visible_fields).uniq
|
|
@@ -1437,6 +1437,65 @@ module Parse
|
|
|
1437
1437
|
end
|
|
1438
1438
|
end
|
|
1439
1439
|
|
|
1440
|
+
# Inspect one effective object permission for this user. The richer
|
|
1441
|
+
# decision distinguishes a definite denial from missing local evidence.
|
|
1442
|
+
# This is a policy preflight; Parse Server remains authoritative.
|
|
1443
|
+
#
|
|
1444
|
+
# @param object [Parse::Object] the Parse object to check.
|
|
1445
|
+
# @param operation [Symbol] `:read`, `:write`, or `:delete`.
|
|
1446
|
+
# @param client [Parse::Client, nil] application whose CLP/roles to inspect.
|
|
1447
|
+
# @param authenticated [Boolean, nil] explicit authentication assertion.
|
|
1448
|
+
# When omitted, an attached session token is required for user/role-
|
|
1449
|
+
# specific grants; public grants can still be answered without one.
|
|
1450
|
+
# @param max_role_depth [Integer] inherited-role traversal limit.
|
|
1451
|
+
# @return [Parse::Access::Decision]
|
|
1452
|
+
def access_decision(object, operation, client: nil, authenticated: nil,
|
|
1453
|
+
max_role_depth: 10)
|
|
1454
|
+
require_relative "../../access" unless defined?(Parse::Access)
|
|
1455
|
+
Parse::Access.check(
|
|
1456
|
+
principal: self,
|
|
1457
|
+
object: object,
|
|
1458
|
+
operation: operation,
|
|
1459
|
+
client: client,
|
|
1460
|
+
authenticated: authenticated,
|
|
1461
|
+
max_role_depth: max_role_depth,
|
|
1462
|
+
)
|
|
1463
|
+
end
|
|
1464
|
+
|
|
1465
|
+
# Inspect read, write, and delete while sharing one role-graph lookup.
|
|
1466
|
+
# @return [Hash<Symbol, Parse::Access::Decision>]
|
|
1467
|
+
def access_decisions(object, client: nil, authenticated: nil, max_role_depth: 10)
|
|
1468
|
+
require_relative "../../access" unless defined?(Parse::Access)
|
|
1469
|
+
Parse::Access.check_all(
|
|
1470
|
+
principal: self,
|
|
1471
|
+
object: object,
|
|
1472
|
+
client: client,
|
|
1473
|
+
authenticated: authenticated,
|
|
1474
|
+
max_role_depth: max_role_depth,
|
|
1475
|
+
)
|
|
1476
|
+
end
|
|
1477
|
+
|
|
1478
|
+
# Return whether local evidence definitively grants read access. Unknown
|
|
1479
|
+
# states (partial objects, unresolved CLP/roles, or an id-only user) fail
|
|
1480
|
+
# closed.
|
|
1481
|
+
# @return [Boolean]
|
|
1482
|
+
def can_read?(object, **options)
|
|
1483
|
+
access_decision(object, :read, **options).allowed?
|
|
1484
|
+
end
|
|
1485
|
+
|
|
1486
|
+
# Return whether local evidence definitively grants update access.
|
|
1487
|
+
# @return [Boolean]
|
|
1488
|
+
def can_write?(object, **options)
|
|
1489
|
+
access_decision(object, :write, **options).allowed?
|
|
1490
|
+
end
|
|
1491
|
+
|
|
1492
|
+
# Return whether local evidence definitively grants delete access. Delete
|
|
1493
|
+
# uses the ACL write grant plus the class's delete CLP.
|
|
1494
|
+
# @return [Boolean]
|
|
1495
|
+
def can_delete?(object, **options)
|
|
1496
|
+
access_decision(object, :delete, **options).allowed?
|
|
1497
|
+
end
|
|
1498
|
+
|
|
1440
1499
|
# Return the transitive upward closure of role names this user
|
|
1441
1500
|
# inherits permissions from.
|
|
1442
1501
|
#
|
data/lib/parse/model/clp.rb
CHANGED
|
@@ -324,10 +324,10 @@ module Parse
|
|
|
324
324
|
# Determine if we should include defaults
|
|
325
325
|
# Auto-enable if any CLP settings exist and no explicit choice made
|
|
326
326
|
should_include_defaults = if include_defaults.nil?
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
327
|
+
present? && @default_permission
|
|
328
|
+
else
|
|
329
|
+
include_defaults
|
|
330
|
+
end
|
|
331
331
|
|
|
332
332
|
# Determine the default permission to use
|
|
333
333
|
# Use explicit default_permission if set, otherwise fall back to public
|
|
@@ -109,6 +109,100 @@ module Parse
|
|
|
109
109
|
end
|
|
110
110
|
end
|
|
111
111
|
|
|
112
|
+
# Capture the property values a transaction rollback must restore.
|
|
113
|
+
#
|
|
114
|
+
# Property values live in `@<field>` instance variables (see
|
|
115
|
+
# {Parse::Properties::ClassMethods#property}), so those are what gets
|
|
116
|
+
# snapshotted. {Parse::Object#attributes} is deliberately NOT captured:
|
|
117
|
+
# it returns a SCHEMA map (field name to type symbol), not values, so
|
|
118
|
+
# restoring it never rolled anything back.
|
|
119
|
+
#
|
|
120
|
+
# Restoring it was also actively harmful. `@attributes` is the ivar
|
|
121
|
+
# `ActiveModel::Dirty` keys its behavior on: `mutations_from_database`
|
|
122
|
+
# builds an `AttributeMutationTracker` over `@attributes` the moment
|
|
123
|
+
# that ivar is defined (and a `ForcedMutationTracker` otherwise), while
|
|
124
|
+
# `forget_attribute_assignments` calls `map(&:forgetting_assignment)`
|
|
125
|
+
# on it. Handing either one a schema Hash raised `NoMethodError` on a
|
|
126
|
+
# type symbol and on the `[key, value]` pair `Hash#map` yields. Both
|
|
127
|
+
# sites rescue and warn, so every rollback silently downgraded the
|
|
128
|
+
# object to broken change tracking instead of failing.
|
|
129
|
+
#
|
|
130
|
+
# @param obj [Parse::Object] the object being tracked.
|
|
131
|
+
# @return [Hash] field name to a snapshot of its value. Mutable values
|
|
132
|
+
# are duplicated so in-place mutation between snapshot and rollback
|
|
133
|
+
# cannot corrupt the saved copy.
|
|
134
|
+
def self.snapshot_property_values(obj)
|
|
135
|
+
fields = obj.class.respond_to?(:fields) ? obj.class.fields.keys : []
|
|
136
|
+
fields.each_with_object({}) do |key, snapshot|
|
|
137
|
+
ivar = :"@#{key}"
|
|
138
|
+
next unless obj.instance_variable_defined?(ivar)
|
|
139
|
+
snapshot[ivar] = dup_for_snapshot(obj.instance_variable_get(ivar))
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Copy a property value deeply enough that later in-place mutation of
|
|
144
|
+
# the live value cannot reach the snapshot.
|
|
145
|
+
#
|
|
146
|
+
# A plain `dup` is not sufficient for collection-backed properties:
|
|
147
|
+
# `:array` and association properties hold a {Parse::CollectionProxy},
|
|
148
|
+
# and duplicating the proxy still shares the underlying `@collection`
|
|
149
|
+
# array, so `widget.tags << "b"` would mutate the snapshot too. The
|
|
150
|
+
# proxy's inner array is duplicated as well.
|
|
151
|
+
#
|
|
152
|
+
# @param value [Object] the live property value.
|
|
153
|
+
# @return [Object] a copy safe to hold across the transaction.
|
|
154
|
+
def self.dup_for_snapshot(value)
|
|
155
|
+
copy = begin
|
|
156
|
+
value.dup
|
|
157
|
+
rescue TypeError
|
|
158
|
+
# Symbols, Integers, true/false/nil and other immediates are not
|
|
159
|
+
# duplicable on older rubies; they are also immutable, so sharing
|
|
160
|
+
# the reference is safe.
|
|
161
|
+
return value
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
if copy.instance_variable_defined?(:@collection)
|
|
165
|
+
inner = copy.instance_variable_get(:@collection)
|
|
166
|
+
copy.instance_variable_set(:@collection, inner.dup) if inner.is_a?(Array)
|
|
167
|
+
end
|
|
168
|
+
copy
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Restore the values captured by {snapshot_property_values}.
|
|
172
|
+
#
|
|
173
|
+
# Writes ivars directly rather than going through the property setters,
|
|
174
|
+
# since the setters mark the object dirty and the caller restores the
|
|
175
|
+
# dirty state itself immediately afterwards.
|
|
176
|
+
#
|
|
177
|
+
# @param obj [Parse::Object] the object being rolled back.
|
|
178
|
+
# @param snapshot [Hash] the return value of {snapshot_property_values}.
|
|
179
|
+
# @return [void]
|
|
180
|
+
def self.restore_property_values(obj, snapshot)
|
|
181
|
+
return unless snapshot.is_a?(Hash)
|
|
182
|
+
snapshot.each do |ivar, value|
|
|
183
|
+
obj.instance_variable_set(ivar, value)
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# Roll a single tracked object back to the state captured when it was
|
|
188
|
+
# added to the transaction.
|
|
189
|
+
#
|
|
190
|
+
# @param state [Hash] one entry of the transaction's `original_states`.
|
|
191
|
+
# @return [void]
|
|
192
|
+
def self.rollback_object_state(state)
|
|
193
|
+
obj = state[:object]
|
|
194
|
+
return if obj.nil?
|
|
195
|
+
restore_property_values(obj, state[:property_values])
|
|
196
|
+
obj.instance_variable_set(:@changed_attributes, state[:changed_attributes])
|
|
197
|
+
obj.instance_variable_set(:@id, state[:id])
|
|
198
|
+
# Restore change tracking state. Leaving `@mutations_from_database`
|
|
199
|
+
# nil is fine: `ActiveModel::Dirty` lazily rebuilds it, and with
|
|
200
|
+
# `@attributes` no longer defined on the object it correctly rebuilds
|
|
201
|
+
# a `ForcedMutationTracker`.
|
|
202
|
+
obj.instance_variable_set(:@mutations_from_database, state[:mutations_from_database])
|
|
203
|
+
obj.instance_variable_set(:@mutations_before_last_save, state[:mutations_before_last_save])
|
|
204
|
+
end
|
|
205
|
+
|
|
112
206
|
# Class methods applied to Parse::Object subclasses.
|
|
113
207
|
module ClassMethods
|
|
114
208
|
|
|
@@ -169,7 +263,7 @@ module Parse
|
|
|
169
263
|
if obj.respond_to?(:attributes) && obj.respond_to?(:id) && !original_states.key?(obj.object_id)
|
|
170
264
|
original_states[obj.object_id] = {
|
|
171
265
|
object: obj,
|
|
172
|
-
|
|
266
|
+
property_values: Parse::Core::Actions.snapshot_property_values(obj),
|
|
173
267
|
changed_attributes: obj.instance_variable_get(:@changed_attributes)&.dup || {},
|
|
174
268
|
id: obj.id,
|
|
175
269
|
mutations_from_database: obj.instance_variable_get(:@mutations_from_database),
|
|
@@ -243,13 +337,7 @@ module Parse
|
|
|
243
337
|
|
|
244
338
|
# Rollback local object states
|
|
245
339
|
original_states.each_value do |state|
|
|
246
|
-
|
|
247
|
-
obj.instance_variable_set(:@attributes, state[:attributes])
|
|
248
|
-
obj.instance_variable_set(:@changed_attributes, state[:changed_attributes])
|
|
249
|
-
obj.instance_variable_set(:@id, state[:id])
|
|
250
|
-
# Restore change tracking state
|
|
251
|
-
obj.instance_variable_set(:@mutations_from_database, state[:mutations_from_database])
|
|
252
|
-
obj.instance_variable_set(:@mutations_before_last_save, state[:mutations_before_last_save])
|
|
340
|
+
Parse::Core::Actions.rollback_object_state(state)
|
|
253
341
|
end
|
|
254
342
|
|
|
255
343
|
raise Parse::Error, "Transaction failed: #{error_response.error}"
|
|
@@ -263,13 +351,7 @@ module Parse
|
|
|
263
351
|
|
|
264
352
|
# Rollback local object states on final failure
|
|
265
353
|
original_states.each_value do |state|
|
|
266
|
-
|
|
267
|
-
obj.instance_variable_set(:@attributes, state[:attributes])
|
|
268
|
-
obj.instance_variable_set(:@changed_attributes, state[:changed_attributes])
|
|
269
|
-
obj.instance_variable_set(:@id, state[:id])
|
|
270
|
-
# Restore change tracking state
|
|
271
|
-
obj.instance_variable_set(:@mutations_from_database, state[:mutations_from_database])
|
|
272
|
-
obj.instance_variable_set(:@mutations_before_last_save, state[:mutations_before_last_save])
|
|
354
|
+
Parse::Core::Actions.rollback_object_state(state)
|
|
273
355
|
end
|
|
274
356
|
|
|
275
357
|
raise e
|