parse-stack-next 5.6.0 → 5.7.1

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.
Files changed (121) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +489 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +219 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/associations/belongs_to.rb +4 -0
  75. data/lib/parse/model/associations/collection_proxy.rb +3 -0
  76. data/lib/parse/model/associations/has_many.rb +4 -0
  77. data/lib/parse/model/classes/role.rb +227 -28
  78. data/lib/parse/model/classes/user.rb +60 -1
  79. data/lib/parse/model/clp.rb +4 -4
  80. data/lib/parse/model/core/actions.rb +366 -113
  81. data/lib/parse/model/core/create_lock.rb +0 -2
  82. data/lib/parse/model/core/describe.rb +57 -57
  83. data/lib/parse/model/core/embed_managed.rb +21 -23
  84. data/lib/parse/model/core/fetching.rb +6 -0
  85. data/lib/parse/model/core/indexing.rb +14 -14
  86. data/lib/parse/model/core/parse_reference.rb +1 -1
  87. data/lib/parse/model/core/properties.rb +6 -2
  88. data/lib/parse/model/core/querying.rb +1 -1
  89. data/lib/parse/model/core/schema.rb +2 -2
  90. data/lib/parse/model/core/search_indexing.rb +2 -2
  91. data/lib/parse/model/core/vector_searchable.rb +4 -5
  92. data/lib/parse/model/file.rb +21 -15
  93. data/lib/parse/model/geojson.rb +2 -2
  94. data/lib/parse/model/geopoint.rb +1 -0
  95. data/lib/parse/model/object.rb +136 -66
  96. data/lib/parse/model/pointer.rb +2 -0
  97. data/lib/parse/model/polygon.rb +3 -6
  98. data/lib/parse/model/push.rb +2 -2
  99. data/lib/parse/model/vector.rb +3 -1
  100. data/lib/parse/mongodb.rb +512 -282
  101. data/lib/parse/pipeline_security.rb +3 -0
  102. data/lib/parse/query/constraints.rb +46 -46
  103. data/lib/parse/query.rb +146 -59
  104. data/lib/parse/retrieval/agent_tool.rb +34 -20
  105. data/lib/parse/retrieval/chunk.rb +1 -0
  106. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  107. data/lib/parse/retrieval/reranker.rb +3 -4
  108. data/lib/parse/retrieval/retriever.rb +4 -6
  109. data/lib/parse/schema/index_migrator.rb +26 -26
  110. data/lib/parse/schema/search_index_migrator.rb +19 -19
  111. data/lib/parse/stack/tasks.rb +6 -6
  112. data/lib/parse/stack/version.rb +1 -1
  113. data/lib/parse/stack.rb +10 -10
  114. data/lib/parse/vector_search/hybrid.rb +66 -41
  115. data/lib/parse/vector_search.rb +14 -11
  116. data/lib/parse/webhooks/payload.rb +1 -0
  117. data/lib/parse/webhooks/registration.rb +4 -4
  118. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  119. data/lib/parse/webhooks.rb +120 -5
  120. data/parse-stack-next.gemspec +6 -6
  121. metadata +10 -1
@@ -87,7 +87,7 @@ module Parse
87
87
  end
88
88
  collisions = by_gql_name.select { |_, names| names.size > 1 }
89
89
  return if collisions.empty?
90
- details = collisions.map { |gql, parse| "#{gql} ← #{parse.join(', ')}" }.join('; ')
90
+ details = collisions.map { |gql, parse| "#{gql} ← #{parse.join(", ")}" }.join("; ")
91
91
  raise "Parse::GraphQL::TypeGenerator: graphql_name collisions: #{details}. " \
92
92
  "Parse class names that differ only by underscores collapse to the same " \
93
93
  "GraphQL type name. Rename or generate the conflicting classes separately."
data/lib/parse/graphql.rb CHANGED
@@ -26,11 +26,11 @@ module Parse
26
26
  def available?
27
27
  return @gem_available if defined?(@gem_available)
28
28
  @gem_available = begin
29
- require "graphql"
30
- true
31
- rescue LoadError
32
- false
33
- end
29
+ require "graphql"
30
+ true
31
+ rescue LoadError
32
+ false
33
+ end
34
34
  end
35
35
 
36
36
  # Force-reset the cached availability flag. Test-only.
@@ -364,7 +364,7 @@ module Parse
364
364
  # returned subscription and register callbacks later.
365
365
  # @return [Subscription]
366
366
  def subscribe(class_name, where: {}, fields: nil, keys: nil, watch: nil, session_token: nil,
367
- use_master_key: false, &block)
367
+ use_master_key: false, &block)
368
368
  # Handle Parse::Object subclass
369
369
  if class_name.is_a?(Class) && class_name < Parse::Object
370
370
  class_name = class_name.parse_class
@@ -83,7 +83,7 @@ module Parse
83
83
  prefix_parts = []
84
84
  prefix_parts << "request_id=#{request_id}" if request_id
85
85
  prefix_parts << "class=#{class_name}" if class_name
86
- prefixed = prefix_parts.empty? ? text : "#{prefix_parts.join(' ')} #{text}"
86
+ prefixed = prefix_parts.empty? ? text : "#{prefix_parts.join(" ")} #{text}"
87
87
  super(prefixed)
88
88
  end
89
89
  end
data/lib/parse/lock.rb CHANGED
@@ -93,10 +93,10 @@ module Parse
93
93
  module Lock
94
94
  KEY_PREFIX = "parse-stack:lock:v1:"
95
95
 
96
- DEFAULT_TTL = 3
96
+ DEFAULT_TTL = 3
97
97
  DEFAULT_WAIT = 2.0
98
- MAX_TTL = 30
99
- MAX_WAIT = 30
98
+ MAX_TTL = 30
99
+ MAX_WAIT = 30
100
100
 
101
101
  # Minimum byte-length for an explicit `secret:` kwarg. 16 bytes
102
102
  # ≈ 128 bits of separation between tenants — short enough not to
@@ -196,13 +196,13 @@ module Parse
196
196
  # @raise [Parse::Lock::UnavailableError] when `on_degraded: :raise`
197
197
  # and the store is process-local.
198
198
  def acquire(key, ttl: DEFAULT_TTL, wait: DEFAULT_WAIT,
199
- on_degraded: :warn, secret: :auto, &block)
199
+ on_degraded: :warn, secret: :auto, &block)
200
200
  raise ArgumentError, "block required" unless block_given?
201
201
  validated_key = validate_key!(key)
202
202
  validate_on_degraded!(on_degraded)
203
203
  validate_secret!(secret)
204
- normalized_ttl = clamp(Integer(ttl), 1, MAX_TTL)
205
- normalized_wait = clamp(Float(wait), 0.0, MAX_WAIT)
204
+ normalized_ttl = clamp(Integer(ttl), 1, MAX_TTL)
205
+ normalized_wait = clamp(Float(wait), 0.0, MAX_WAIT)
206
206
 
207
207
  # Route through Parse::LockBackend — the shared module that
208
208
  # also serves Parse::CreateLock. The KEY_PREFIX
@@ -216,15 +216,12 @@ module Parse
216
216
  # up the operator-configured secret if one exists; the
217
217
  # explicit-String branch overrides it; the explicit-nil
218
218
  # branch opts out without a warn.
219
- resolved_secret =
220
- case secret
221
- when :auto then Parse::LockBackend.lock_secret_for(store: store, source: "Parse::Lock")
222
- when String then secret
223
- when nil then nil
219
+ resolved_secret = case secret
220
+ when :auto then Parse::LockBackend.lock_secret_for(store: store, source: "Parse::Lock")
221
+ when String then secret
222
+ when nil then nil
224
223
  end
225
- digest = resolved_secret \
226
- ? OpenSSL::HMAC.hexdigest("SHA256", resolved_secret, validated_key) \
227
- : Digest::SHA256.hexdigest(validated_key)
224
+ digest = resolved_secret ? OpenSSL::HMAC.hexdigest("SHA256", resolved_secret, validated_key) : Digest::SHA256.hexdigest(validated_key)
228
225
  store_key = "#{KEY_PREFIX}#{digest}"
229
226
 
230
227
  if Parse::LockBackend.degraded_store?(store)
@@ -238,13 +235,13 @@ module Parse
238
235
  # cross-process owner here — the Mutex IS the exclusion — so a
239
236
  # fresh UUID is purely for signature parity / local fencing.
240
237
  return Parse::LockBackend.synchronize_process_mutex(store_key) do
241
- yield SecureRandom.uuid
242
- end
238
+ yield SecureRandom.uuid
239
+ end
243
240
  end
244
241
 
245
- owner = SecureRandom.uuid
242
+ owner = SecureRandom.uuid
246
243
  acquired_at = nil
247
- start = Parse::LockBackend.monotonic_now
244
+ start = Parse::LockBackend.monotonic_now
248
245
 
249
246
  loop do
250
247
  if Parse::LockBackend.try_acquire(store, store_key, owner, normalized_ttl)
@@ -102,7 +102,7 @@ module Parse
102
102
  # error (Parse::CreateLockUnavailableError vs
103
103
  # Parse::Lock::UnavailableError) without coupling here.
104
104
  def handle_degraded(mode, key, source: "Parse::LockBackend",
105
- unavailable_error: nil)
105
+ unavailable_error: nil)
106
106
  case mode
107
107
  when :raise
108
108
  err = unavailable_error || Parse::Error
@@ -236,9 +236,9 @@ module Parse
236
236
  mongo_local = "_p_#{pointer_field}"
237
237
  if foreign_has_parse_reference?(target_class)
238
238
  replace_keys(spec,
239
- "from" => from_collection,
240
- "localField" => mongo_local,
241
- "foreignField" => PARSE_REFERENCE_REMOTE)
239
+ "from" => from_collection,
240
+ "localField" => mongo_local,
241
+ "foreignField" => PARSE_REFERENCE_REMOTE)
242
242
  else
243
243
  as_value = read_string(spec, "as")
244
244
  let_var = "rwLookupId_#{pointer_field}"
@@ -262,9 +262,9 @@ module Parse
262
262
  mongo_foreign = "_p_#{pointer_field}"
263
263
  if foreign_has_parse_reference?(local_class)
264
264
  replace_keys(spec,
265
- "from" => from_collection,
266
- "localField" => PARSE_REFERENCE_REMOTE,
267
- "foreignField" => mongo_foreign)
265
+ "from" => from_collection,
266
+ "localField" => PARSE_REFERENCE_REMOTE,
267
+ "foreignField" => mongo_foreign)
268
268
  else
269
269
  as_value = read_string(spec, "as")
270
270
  let_var = "rwReverseId_#{pointer_field}"
@@ -243,6 +243,10 @@ module Parse
243
243
  instance_variable_set ivar, val
244
244
  end
245
245
 
246
+ # Capture after lazy hydration, before a caller can mutate an
247
+ # object returned by this association getter.
248
+ send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
249
+
246
250
  # Track association source for N+1 detection when returning an unfetched pointer
247
251
  # Uses a registry instead of setting instance variables on the pointer object
248
252
  if val.is_a?(Parse::Pointer) && val.pointer? && Parse.warn_on_n_plus_one
@@ -359,6 +359,9 @@ module Parse
359
359
 
360
360
  # Notifies the delegate that the collection changed.
361
361
  def notify_will_change!
362
+ if @delegate && @delegate.respond_to?(:_capture_transaction_state!, true)
363
+ @delegate.send(:_capture_transaction_state!)
364
+ end
362
365
  collection_will_change!
363
366
  forward "#{@key}_will_change!"
364
367
  end
@@ -502,6 +502,10 @@ module Parse
502
502
  val = instance_variable_get ivar
503
503
  end
504
504
 
505
+ # Capture before this getter materializes or returns a mutable
506
+ # proxy that can be changed in place.
507
+ send(:_capture_transaction_state!) if respond_to?(:_capture_transaction_state!, true)
508
+
505
509
  # if the result is not a collection proxy, then create a new one.
506
510
  unless val.is_a?(Parse::PointerCollectionProxy)
507
511
  results = []
@@ -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 = Parse::Role.all(users: user_pointer)
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(direct_roles, max_depth: max_depth)
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 defined?(::Mongo::Error::ConnectionFailure) &&
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
- def expand_inheritance_upward(starting_roles, max_depth: 10)
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 = Parse::Role.all(roles: role)
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
- visited << id
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
- direct_users = users.all
691
+ visited << id
577
692
 
578
- child_roles = roles.all
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(max_depth: max_depth - 1, visited: visited)
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::User.all(:objectId.in => ids.to_a)
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 defined?(::Mongo::Error::ConnectionFailure) &&
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
- def all_parent_role_names(max_depth: 10)
692
- Parse::Role.expand_inheritance_upward([self], max_depth: max_depth)
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
  #