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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +489 -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 +219 -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/associations/belongs_to.rb +4 -0
- data/lib/parse/model/associations/collection_proxy.rb +3 -0
- data/lib/parse/model/associations/has_many.rb +4 -0
- 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 +366 -113
- 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 +6 -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 +6 -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 +136 -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 +120 -5
- data/parse-stack-next.gemspec +6 -6
- 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(
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
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
|
data/lib/parse/live_query.rb
CHANGED
|
@@ -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(
|
|
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
|
|
96
|
+
DEFAULT_TTL = 3
|
|
97
97
|
DEFAULT_WAIT = 2.0
|
|
98
|
-
MAX_TTL
|
|
99
|
-
MAX_WAIT
|
|
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
|
-
|
|
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
|
|
205
|
-
normalized_wait = clamp(Float(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
|
-
|
|
221
|
-
when
|
|
222
|
-
when
|
|
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
|
-
|
|
242
|
-
|
|
238
|
+
yield SecureRandom.uuid
|
|
239
|
+
end
|
|
243
240
|
end
|
|
244
241
|
|
|
245
|
-
owner
|
|
242
|
+
owner = SecureRandom.uuid
|
|
246
243
|
acquired_at = nil
|
|
247
|
-
start
|
|
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)
|
data/lib/parse/lock_backend.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 =
|
|
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
|
#
|