parse-stack-next 5.8.0 → 5.8.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 +318 -0
- data/README.md +25 -19
- data/docs/caching.md +50 -0
- data/docs/mcp_guide.md +15 -3
- data/docs/webhooks_guide.md +59 -7
- data/lib/parse/acl_scope.rb +71 -9
- data/lib/parse/agent/describe.rb +8 -2
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent.rb +205 -20
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search.rb +49 -1
- data/lib/parse/authorization.rb +235 -3
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +35 -2
- data/lib/parse/client.rb +26 -2
- data/lib/parse/clp_scope.rb +37 -3
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/associations/collection_proxy.rb +19 -11
- data/lib/parse/model/associations/has_many.rb +11 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
- data/lib/parse/model/classes/role.rb +23 -0
- data/lib/parse/model/classes/session.rb +279 -19
- data/lib/parse/model/classes/user.rb +33 -12
- data/lib/parse/model/core/actions.rb +19 -4
- data/lib/parse/model/core/fetching.rb +29 -2
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/object.rb +52 -1
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +19 -1
- data/lib/parse/query/constraint.rb +1 -1
- data/lib/parse/query/constraints.rb +31 -27
- data/lib/parse/query.rb +793 -55
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +19 -0
- data/lib/parse/vector_search/hybrid.rb +74 -15
- data/lib/parse/vector_search.rb +214 -7
- data/lib/parse/webhooks/payload.rb +43 -0
- data/lib/parse/webhooks.rb +312 -9
- metadata +1 -1
data/lib/parse/authorization.rb
CHANGED
|
@@ -116,6 +116,49 @@ module Parse
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
118
|
|
|
119
|
+
# Process-local record of session objectId to owning user id, kept apart
|
|
120
|
+
# from the identity plane so no session token can ever read a record.
|
|
121
|
+
# Entries expire after `ttl` and the map holds at most {MAX_ENTRIES},
|
|
122
|
+
# dropping the oldest first. Values are stored typed
|
|
123
|
+
# (`{"session_owner" => user_id}`) so a shared store passed in its place
|
|
124
|
+
# cannot confuse a record with an identity entry either.
|
|
125
|
+
class SessionOwnerMap
|
|
126
|
+
MAX_ENTRIES = 10_000
|
|
127
|
+
|
|
128
|
+
def initialize
|
|
129
|
+
@data = {}
|
|
130
|
+
@mutex = Mutex.new
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def get(key)
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
entry = @data[key]
|
|
136
|
+
return nil if entry.nil?
|
|
137
|
+
if entry[:expires_at] < Time.now
|
|
138
|
+
@data.delete(key)
|
|
139
|
+
return nil
|
|
140
|
+
end
|
|
141
|
+
entry[:value]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def set(key, value, ttl:)
|
|
146
|
+
@mutex.synchronize do
|
|
147
|
+
@data.delete(key)
|
|
148
|
+
@data[key] = { value: value, expires_at: Time.now + ttl }
|
|
149
|
+
@data.shift while @data.size > MAX_ENTRIES
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def invalidate(key)
|
|
154
|
+
@mutex.synchronize { @data.delete(key) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def clear
|
|
158
|
+
@mutex.synchronize { @data.clear }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
119
162
|
# The outcome of resolving a caller. `user_id` is the `_User.objectId`
|
|
120
163
|
# owning the session, or `nil` for an anonymous caller. `role_names` is a
|
|
121
164
|
# `Set` of bare role names (no `role:` prefix) the user inherits
|
|
@@ -189,8 +232,26 @@ module Parse
|
|
|
189
232
|
@role_cache_ttl = DEFAULT_ROLE_TTL
|
|
190
233
|
@upstream_role_reader = nil
|
|
191
234
|
@compare_upstream_roles = false
|
|
235
|
+
# Bumped by every invalidation. A token resolution captures it before
|
|
236
|
+
# calling `/users/me` and does not cache its answer when it moved, so
|
|
237
|
+
# a resolve already in flight when a session is revoked cannot put
|
|
238
|
+
# the revoked token back into the plane. A generation-capable plane
|
|
239
|
+
# (the shared Redis identity plane) keeps a plane-wide counter too,
|
|
240
|
+
# so the same holds across processes. See {#lookup_user_id}.
|
|
241
|
+
@invalidation_epoch = 0
|
|
242
|
+
@epoch_mutex = Mutex.new
|
|
243
|
+
@session_owner_cache = SessionOwnerMap.new
|
|
192
244
|
end
|
|
193
245
|
|
|
246
|
+
# Where {#remember_session_owner} records which user owns a session
|
|
247
|
+
# objectId. A separate store from the identity plane, so a session
|
|
248
|
+
# token can never read a record. Defaults to a process-local
|
|
249
|
+
# {SessionOwnerMap}; set a shared store (anything with `get`, `set`
|
|
250
|
+
# with `ttl:`, `invalidate`, and `clear`) to share records across
|
|
251
|
+
# processes.
|
|
252
|
+
# @return [Object]
|
|
253
|
+
attr_accessor :session_owner_cache
|
|
254
|
+
|
|
194
255
|
# Apply settings, leaving anything not passed unchanged.
|
|
195
256
|
# @return [self]
|
|
196
257
|
def configure(identity_cache: nil, role_cache: nil,
|
|
@@ -246,6 +307,7 @@ module Parse
|
|
|
246
307
|
# @param session_token [String]
|
|
247
308
|
def invalidate(session_token)
|
|
248
309
|
return if session_token.nil?
|
|
310
|
+
bump_invalidation_epoch!
|
|
249
311
|
@identity_cache.invalidate(session_token.to_s)
|
|
250
312
|
end
|
|
251
313
|
|
|
@@ -268,6 +330,7 @@ module Parse
|
|
|
268
330
|
def invalidate_user(user_id)
|
|
269
331
|
return if user_id.nil? || user_id.to_s.empty?
|
|
270
332
|
uid = user_id.to_s
|
|
333
|
+
bump_invalidation_epoch!
|
|
271
334
|
cache = @identity_cache
|
|
272
335
|
if generation_capable?(cache) && cache.respond_to?(:bump_generation)
|
|
273
336
|
cache.bump_generation(uid)
|
|
@@ -294,8 +357,42 @@ module Parse
|
|
|
294
357
|
|
|
295
358
|
# Drop every entry in both planes.
|
|
296
359
|
def reset_caches!
|
|
360
|
+
bump_invalidation_epoch!
|
|
297
361
|
@identity_cache.clear if @identity_cache.respond_to?(:clear)
|
|
298
362
|
@role_cache.clear if @role_cache.respond_to?(:clear)
|
|
363
|
+
# A plane clear takes the plane-wide marker with it. Bump it again so
|
|
364
|
+
# it does not sit at a value a lookup that started before the reset
|
|
365
|
+
# could have captured.
|
|
366
|
+
bump_plane_epoch!
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Record which user owns a session objectId, so a later delete of that
|
|
370
|
+
# session can drop the owner's cached identities even when the delete
|
|
371
|
+
# can no longer read the session row (it is already gone, or not
|
|
372
|
+
# visible to the caller). Only the owner's user id is stored, never the
|
|
373
|
+
# token. Called when a `_Session` row with both is loaded from the
|
|
374
|
+
# server. The entry lives as long as an identity entry.
|
|
375
|
+
# @param session_id [String]
|
|
376
|
+
# @param user_id [String]
|
|
377
|
+
# @return [void]
|
|
378
|
+
def remember_session_owner(session_id, user_id)
|
|
379
|
+
return if session_id.to_s.empty? || user_id.to_s.empty?
|
|
380
|
+
@session_owner_cache.set(session_id.to_s, { "session_owner" => user_id.to_s }, ttl: @identity_cache_ttl)
|
|
381
|
+
nil
|
|
382
|
+
rescue StandardError
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The owner recorded by {#remember_session_owner}, or nil.
|
|
387
|
+
# @param session_id [String]
|
|
388
|
+
# @return [String, nil]
|
|
389
|
+
def session_owner(session_id)
|
|
390
|
+
return nil if session_id.to_s.empty?
|
|
391
|
+
value = @session_owner_cache.get(session_id.to_s)
|
|
392
|
+
owner = value.is_a?(Hash) ? value["session_owner"] : nil
|
|
393
|
+
owner.is_a?(String) && !owner.empty? ? owner : nil
|
|
394
|
+
rescue StandardError
|
|
395
|
+
nil
|
|
299
396
|
end
|
|
300
397
|
|
|
301
398
|
def inspect
|
|
@@ -314,6 +411,14 @@ module Parse
|
|
|
314
411
|
cached = cached_user_id(session_token)
|
|
315
412
|
return cached unless cached.nil?
|
|
316
413
|
|
|
414
|
+
# Captured before `/users/me`: an invalidation that lands while the
|
|
415
|
+
# lookup is in flight must win over the answer it returns. The plane
|
|
416
|
+
# marker is created first if a clear removed it, so the snapshot
|
|
417
|
+
# never holds "no marker", which a concurrent reset also produces.
|
|
418
|
+
ensure_plane_marker!
|
|
419
|
+
snapshot = invalidation_snapshot
|
|
420
|
+
prior_uid, prior_gen = prior_generation(session_token)
|
|
421
|
+
|
|
317
422
|
response = begin
|
|
318
423
|
# cache: false: a revoked or expired token must not re-resolve
|
|
319
424
|
# from a cached /users/me response after its identity entry is
|
|
@@ -331,10 +436,136 @@ module Parse
|
|
|
331
436
|
raise InvalidSession, "session token resolved no user objectId" if user_id.nil? || user_id.to_s.empty?
|
|
332
437
|
|
|
333
438
|
user_id = user_id.to_s
|
|
334
|
-
|
|
439
|
+
# A stale entry for the same user tells us its generation from
|
|
440
|
+
# before the lookup; storing under it lets a bump made across
|
|
441
|
+
# processes during the lookup still reject the entry.
|
|
442
|
+
store_unless_invalidated(session_token, user_id, snapshot,
|
|
443
|
+
gen: prior_uid == user_id ? prior_gen : nil)
|
|
335
444
|
user_id
|
|
336
445
|
end
|
|
337
446
|
|
|
447
|
+
# Cache a resolved identity only if no invalidation happened since
|
|
448
|
+
# `snapshot` was taken, without a window between the check and the
|
|
449
|
+
# write.
|
|
450
|
+
#
|
|
451
|
+
# Every invalidation bumps the counters BEFORE it drops entries. The
|
|
452
|
+
# write is checked before and re-checked after; a change seen by the
|
|
453
|
+
# second check evicts what was just written. So an invalidation either
|
|
454
|
+
# bumped before the re-check (the entry is evicted here) or bumped
|
|
455
|
+
# after it, which means it also drops entries after the write (the
|
|
456
|
+
# entry is removed, or its generation goes stale, by the invalidation
|
|
457
|
+
# itself). Either way the revoked identity does not stay cached. The
|
|
458
|
+
# plane-wide counter extends this to invalidations made by other
|
|
459
|
+
# processes sharing a generation-capable plane.
|
|
460
|
+
def store_unless_invalidated(session_token, user_id, snapshot, gen: nil)
|
|
461
|
+
return unless snapshot_usable?(snapshot)
|
|
462
|
+
return unless invalidation_snapshot == snapshot
|
|
463
|
+
store_user_id(session_token, user_id, gen: gen)
|
|
464
|
+
return if invalidation_snapshot == snapshot
|
|
465
|
+
@identity_cache.invalidate(session_token)
|
|
466
|
+
rescue StandardError
|
|
467
|
+
# Not caching is always safe: the next read resolves again.
|
|
468
|
+
begin
|
|
469
|
+
@identity_cache.invalidate(session_token)
|
|
470
|
+
rescue StandardError
|
|
471
|
+
nil
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# The process counter plus, on a generation-capable plane, the
|
|
476
|
+
# plane-wide counter shared by every process using that plane.
|
|
477
|
+
def invalidation_snapshot
|
|
478
|
+
[current_invalidation_epoch, plane_epoch]
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# A snapshot whose plane counter could not be read cannot prove that no
|
|
482
|
+
# other process invalidated during the lookup, so it never caches.
|
|
483
|
+
#
|
|
484
|
+
# An absent marker on a plane that keeps one is not usable either: a
|
|
485
|
+
# reset in another process deletes the marker before it installs a new
|
|
486
|
+
# one, so "absent" before and after the write cannot prove that no reset
|
|
487
|
+
# ran in between. The re-check after the write compares against a
|
|
488
|
+
# present marker, so reading it absent there evicts the write.
|
|
489
|
+
def snapshot_usable?(snapshot)
|
|
490
|
+
snapshot[1] != :unavailable && snapshot[1] != :absent
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Create the plane-wide marker when the plane keeps one and it is
|
|
494
|
+
# missing (a fresh plane, or one a reset has just cleared).
|
|
495
|
+
def ensure_plane_marker!
|
|
496
|
+
cache = @identity_cache
|
|
497
|
+
if cache.respond_to?(:invalidation_nonce)
|
|
498
|
+
cache.bump_invalidation_nonce if cache.invalidation_nonce.nil?
|
|
499
|
+
elsif generation_capable?(cache) && cache.respond_to?(:bump_generation)
|
|
500
|
+
gen = cache.generation(PLANE_EPOCH_SUBJECT)
|
|
501
|
+
cache.bump_generation(PLANE_EPOCH_SUBJECT) if gen.nil? || gen.to_i.zero?
|
|
502
|
+
end
|
|
503
|
+
rescue StandardError
|
|
504
|
+
nil
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def bump_invalidation_epoch!
|
|
508
|
+
@epoch_mutex.synchronize { @invalidation_epoch += 1 }
|
|
509
|
+
bump_plane_epoch!
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def current_invalidation_epoch
|
|
513
|
+
@epoch_mutex.synchronize { @invalidation_epoch }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Reserved generation subject for the plane-wide invalidation counter
|
|
517
|
+
# on a custom generation-capable plane without an invalidation nonce.
|
|
518
|
+
# Parse objectIds are alphanumeric, so it cannot name a real user.
|
|
519
|
+
PLANE_EPOCH_SUBJECT = "~identity-invalidations"
|
|
520
|
+
|
|
521
|
+
# The plane-wide invalidation marker: a random nonce replaced on every
|
|
522
|
+
# invalidation when the plane offers one ({Parse::Cache::SubCache}
|
|
523
|
+
# does), else a generation counter. nil when the plane has neither,
|
|
524
|
+
# `:absent` when the plane keeps one but it is missing (fresh or just
|
|
525
|
+
# cleared), `:unavailable` when reading it failed. A nonce never repeats,
|
|
526
|
+
# so a plane clear that drops it cannot make an old value come back. A
|
|
527
|
+
# generation counter can: a custom plane whose `clear` also deletes its
|
|
528
|
+
# generations restarts the counter, so a lookup racing another
|
|
529
|
+
# process's reset is bounded by `identity_cache_ttl`. Such a plane
|
|
530
|
+
# should implement `invalidation_nonce` / `bump_invalidation_nonce`.
|
|
531
|
+
def plane_epoch
|
|
532
|
+
cache = @identity_cache
|
|
533
|
+
if cache.respond_to?(:invalidation_nonce)
|
|
534
|
+
nonce = cache.invalidation_nonce
|
|
535
|
+
return nonce.nil? ? :absent : nonce
|
|
536
|
+
end
|
|
537
|
+
return nil unless generation_capable?(cache)
|
|
538
|
+
gen = cache.generation(PLANE_EPOCH_SUBJECT)
|
|
539
|
+
gen.nil? || gen.to_i.zero? ? :absent : gen
|
|
540
|
+
rescue StandardError
|
|
541
|
+
:unavailable
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def bump_plane_epoch!
|
|
545
|
+
cache = @identity_cache
|
|
546
|
+
if cache.respond_to?(:bump_invalidation_nonce)
|
|
547
|
+
cache.bump_invalidation_nonce
|
|
548
|
+
elsif generation_capable?(cache) && cache.respond_to?(:bump_generation)
|
|
549
|
+
cache.bump_generation(PLANE_EPOCH_SUBJECT)
|
|
550
|
+
end
|
|
551
|
+
rescue StandardError
|
|
552
|
+
nil
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# The user id and that user's current generation from a stale entry
|
|
556
|
+
# already stored for the token, read before `/users/me`.
|
|
557
|
+
# @return [Array(String, Object), Array(nil, nil)]
|
|
558
|
+
def prior_generation(session_token)
|
|
559
|
+
cache = @identity_cache
|
|
560
|
+
return [nil, nil] unless generation_capable?(cache)
|
|
561
|
+
raw = cache.get(session_token)
|
|
562
|
+
uid = raw.is_a?(Hash) ? (raw["user_id"] || raw[:user_id]) : nil
|
|
563
|
+
return [nil, nil] if uid.nil?
|
|
564
|
+
[uid.to_s, cache.generation(uid.to_s)]
|
|
565
|
+
rescue StandardError
|
|
566
|
+
[nil, nil]
|
|
567
|
+
end
|
|
568
|
+
|
|
338
569
|
# Read the identity plane and, where the plane supports it, check that
|
|
339
570
|
# the entry's generation is still current.
|
|
340
571
|
#
|
|
@@ -369,10 +600,11 @@ module Parse
|
|
|
369
600
|
|
|
370
601
|
# Write the identity entry, tagging it with the subject's current
|
|
371
602
|
# generation when the plane can track one.
|
|
372
|
-
def store_user_id(session_token, user_id)
|
|
603
|
+
def store_user_id(session_token, user_id, gen: nil)
|
|
373
604
|
cache = @identity_cache
|
|
374
605
|
if generation_capable?(cache)
|
|
375
|
-
|
|
606
|
+
gen = cache.generation(user_id) if gen.nil?
|
|
607
|
+
cache.set(session_token, { "user_id" => user_id, "gen" => gen },
|
|
376
608
|
ttl: @identity_cache_ttl)
|
|
377
609
|
else
|
|
378
610
|
cache.set(session_token, user_id, ttl: @identity_cache_ttl)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
require "set"
|
|
6
7
|
|
|
7
8
|
module Parse
|
|
@@ -163,6 +164,25 @@ module Parse
|
|
|
163
164
|
value
|
|
164
165
|
end
|
|
165
166
|
|
|
167
|
+
# A random marker replaced on every identity invalidation, read by
|
|
168
|
+
# `Parse::Authorization` before and after it caches a token so an
|
|
169
|
+
# invalidation from any process in between is detected. A nonce rather
|
|
170
|
+
# than a counter: a plane clear drops it, and a counter restarting at
|
|
171
|
+
# 0 could repeat a value an in-flight lookup captured.
|
|
172
|
+
# @return [String, nil] nil when never set or cleared.
|
|
173
|
+
def invalidation_nonce
|
|
174
|
+
value = @store[invalidation_nonce_key]
|
|
175
|
+
value.nil? ? nil : value.to_s
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Replace the invalidation nonce.
|
|
179
|
+
# @return [String] the new nonce.
|
|
180
|
+
def bump_invalidation_nonce
|
|
181
|
+
nonce = SecureRandom.hex(16)
|
|
182
|
+
@store.store(invalidation_nonce_key, nonce, {})
|
|
183
|
+
nonce
|
|
184
|
+
end
|
|
185
|
+
|
|
166
186
|
# @return [Float] the last invalidation epoch, 0.0 when never set.
|
|
167
187
|
def epoch
|
|
168
188
|
@store[epoch_key].to_f
|
|
@@ -253,6 +273,12 @@ module Parse
|
|
|
253
273
|
@keyspace.key(@family, "meta", "epoch")
|
|
254
274
|
end
|
|
255
275
|
|
|
276
|
+
# Sits beside the epoch under the reserved `meta` segment; a token key
|
|
277
|
+
# is a digest, so no token can name it.
|
|
278
|
+
def invalidation_nonce_key
|
|
279
|
+
@keyspace.key(@family, "meta", "invalidation")
|
|
280
|
+
end
|
|
281
|
+
|
|
256
282
|
# Generations live in the same family so a plane clear takes them with it,
|
|
257
283
|
# and are namespaced under `gen:` so they cannot collide with an entry
|
|
258
284
|
# whose logical key happens to be a user id.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "faraday"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
require "active_support"
|
|
6
7
|
require "active_support/core_ext"
|
|
7
8
|
|
|
@@ -15,6 +16,18 @@ module Parse
|
|
|
15
16
|
include Parse::Protocol
|
|
16
17
|
# @!visibility private
|
|
17
18
|
DISABLE_MASTER_KEY = "X-Disable-Parse-Master-Key".freeze
|
|
19
|
+
# SDK-internal marker for a request that reads metadata (a class
|
|
20
|
+
# schema, the role graph) while enforcing a scope. Such a request keeps
|
|
21
|
+
# the master key inside {Parse.without_master_key}, which governs row
|
|
22
|
+
# access. The header carries a random per-process token and is removed
|
|
23
|
+
# before the request is logged or sent, so a header copied from user
|
|
24
|
+
# input cannot set it. {Parse::Client#request} adds it only for the
|
|
25
|
+
# `metadata_master:` request option set to
|
|
26
|
+
# {Parse::Client::METADATA_MASTER_REQUEST}.
|
|
27
|
+
# @!visibility private
|
|
28
|
+
METADATA_MASTER = "X-Parse-Stack-Metadata-Master".freeze
|
|
29
|
+
# @!visibility private
|
|
30
|
+
METADATA_MASTER_TOKEN = SecureRandom.hex(32).freeze
|
|
18
31
|
# @return [String] the application id for this Parse endpoint.
|
|
19
32
|
attr_accessor :application_id
|
|
20
33
|
# @return [String] the REST API Key for this Parse endpoint.
|
|
@@ -67,7 +80,12 @@ module Parse
|
|
|
67
80
|
# 3. A session-token-authenticated request (the existing check
|
|
68
81
|
# below; session token wins over master key).
|
|
69
82
|
header_disable = env[:request_headers][DISABLE_MASTER_KEY].present?
|
|
70
|
-
|
|
83
|
+
# An SDK metadata read keeps the master key inside the block. Always
|
|
84
|
+
# remove the marker so it never reaches the logger or the server.
|
|
85
|
+
metadata_value = env[:request_headers].delete(METADATA_MASTER)
|
|
86
|
+
metadata_master = metadata_value.is_a?(String) &&
|
|
87
|
+
ActiveSupport::SecurityUtils.secure_compare(metadata_value, METADATA_MASTER_TOKEN)
|
|
88
|
+
fiber_disable = Parse.master_key_disabled? && !metadata_master
|
|
71
89
|
unless @master_key.blank? || header_disable || fiber_disable
|
|
72
90
|
headers[MASTER_KEY] = @master_key
|
|
73
91
|
end
|
data/lib/parse/client/batch.rb
CHANGED
|
@@ -266,7 +266,10 @@ class Array
|
|
|
266
266
|
# Each object whose delete succeeds has its local state updated the same
|
|
267
267
|
# way {Parse::Object#destroy} updates it. Objects whose delete fails are
|
|
268
268
|
# left untouched; inspect the returned batch's responses to find them.
|
|
269
|
-
# Destroy callbacks are not run.
|
|
269
|
+
# Destroy callbacks are not run. Every {Parse::Session} and {Parse::User}
|
|
270
|
+
# whose delete succeeded, or reported "object not found" (the row is
|
|
271
|
+
# already gone, for example revoked elsewhere), is dropped from its
|
|
272
|
+
# client's identity plane, as their single-object destroy does.
|
|
270
273
|
# @example
|
|
271
274
|
# # assume Post and Author are Parse models
|
|
272
275
|
# author = Author.first
|
|
@@ -281,6 +284,19 @@ class Array
|
|
|
281
284
|
raise ArgumentError, "Array#destroy requires Parse::Object elements; " \
|
|
282
285
|
"this array holds none (#{first.class})"
|
|
283
286
|
end
|
|
287
|
+
# A session deleted from a pointer or a partial fetch carries no token or
|
|
288
|
+
# owner to drop from the identity cache; look them up first, in one
|
|
289
|
+
# query per client.
|
|
290
|
+
Parse::Session.send(:_preload_identity_for_destroy!, targets) if defined?(Parse::Session)
|
|
291
|
+
_destroy_batch(targets)
|
|
292
|
+
ensure
|
|
293
|
+
# A looked-up session token is a live credential; never leave it on an
|
|
294
|
+
# object whose delete was skipped or whose batch raised.
|
|
295
|
+
targets&.each { |o| o.send(:_clear_identity_for_destroy!) if _batch_identity_hook?(o, :_clear_identity_for_destroy!) }
|
|
296
|
+
end
|
|
297
|
+
|
|
298
|
+
# @!visibility private
|
|
299
|
+
def _destroy_batch(targets)
|
|
284
300
|
batch = Parse::BatchOperation.new
|
|
285
301
|
objects = {}
|
|
286
302
|
targets.each do |o|
|
|
@@ -292,7 +308,13 @@ class Array
|
|
|
292
308
|
end
|
|
293
309
|
batch.submit do |request, response|
|
|
294
310
|
o = objects[request.tag]
|
|
295
|
-
next unless o
|
|
311
|
+
next unless o
|
|
312
|
+
# Sessions and users drop their identity-plane entries when the delete
|
|
313
|
+
# applied or the row is already gone ("object not found"), so a revoked
|
|
314
|
+
# token stops resolving now. They decide from the response; a denied
|
|
315
|
+
# delete leaves the cache alone.
|
|
316
|
+
o.send(:_after_batch_destroy, response) if _batch_identity_hook?(o, :_after_batch_destroy)
|
|
317
|
+
next unless response.respond_to?(:success?) && response.success?
|
|
296
318
|
# Mirror Parse::Object#destroy: keep the id and mark the object
|
|
297
319
|
# destroyed so it reports `destroyed?` and a later save refuses.
|
|
298
320
|
o.instance_variable_set(:@_destroyed, true)
|
|
@@ -300,6 +322,17 @@ class Array
|
|
|
300
322
|
end
|
|
301
323
|
batch
|
|
302
324
|
end
|
|
325
|
+
private :_destroy_batch
|
|
326
|
+
|
|
327
|
+
# Whether `o`'s class defines the internal (non-public) identity hook
|
|
328
|
+
# `name`. Checked on the class rather than with `respond_to?(name, true)`
|
|
329
|
+
# so a duck-typed element overriding `respond_to?` cannot break the batch.
|
|
330
|
+
# @!visibility private
|
|
331
|
+
def _batch_identity_hook?(o, name)
|
|
332
|
+
klass = o.class
|
|
333
|
+
klass.private_method_defined?(name) || klass.method_defined?(name)
|
|
334
|
+
end
|
|
335
|
+
private :_batch_identity_hook?
|
|
303
336
|
|
|
304
337
|
# Do not alias method as :delete is already part of array.
|
|
305
338
|
# alias_method :delete, :destroy
|
data/lib/parse/client.rb
CHANGED
|
@@ -283,6 +283,14 @@ module Parse
|
|
|
283
283
|
DEFAULT_RETRIES = 2
|
|
284
284
|
# The wait time in seconds between retries
|
|
285
285
|
RETRY_DELAY = 1.5
|
|
286
|
+
# SDK-internal value for the `metadata_master:` request option. A request
|
|
287
|
+
# that passes it together with `use_master_key: true` reads metadata (a
|
|
288
|
+
# class schema, the role graph) and keeps the master key inside
|
|
289
|
+
# {Parse.without_master_key}. Only this exact object turns it on.
|
|
290
|
+
# @!visibility private
|
|
291
|
+
METADATA_MASTER_REQUEST = Object.new.tap do |o|
|
|
292
|
+
def o.inspect = "#<Parse::Client::METADATA_MASTER_REQUEST>"
|
|
293
|
+
end.freeze
|
|
286
294
|
|
|
287
295
|
# An error when a general response error occurs when communicating with Parse server.
|
|
288
296
|
class ResponseError < Parse::Error; end
|
|
@@ -1319,7 +1327,10 @@ module Parse
|
|
|
1319
1327
|
body ||= _request.body
|
|
1320
1328
|
headers.merge! _request.headers
|
|
1321
1329
|
else
|
|
1322
|
-
|
|
1330
|
+
# The metadata-master sentinel stays off the stored request: a
|
|
1331
|
+
# caller replaying `response.request` later must not inherit it.
|
|
1332
|
+
_request = Parse::Request.new(method, uri, body: body, headers: headers,
|
|
1333
|
+
opts: opts.except(:metadata_master))
|
|
1323
1334
|
# Request copies the headers it is given, so carry its request id
|
|
1324
1335
|
# back onto the outgoing headers.
|
|
1325
1336
|
headers.merge!(_request.headers)
|
|
@@ -1348,6 +1359,14 @@ module Parse
|
|
|
1348
1359
|
# never sent unless the caller explicitly passed `use_master_key: true`
|
|
1349
1360
|
explicit_master = opts.key?(:use_master_key)
|
|
1350
1361
|
|
|
1362
|
+
# SDK metadata reads (class schemas, the role graph) keep the master
|
|
1363
|
+
# key inside `Parse.without_master_key`. Only the internal sentinel
|
|
1364
|
+
# turns this on; a `true` or any other value from a caller does not.
|
|
1365
|
+
# The marker goes on a per-attempt copy of the headers handed to the
|
|
1366
|
+
# connection (below), never on `headers` or the stored request, so a
|
|
1367
|
+
# retry, `response.request`, or an error reporter never carries it.
|
|
1368
|
+
metadata_master = opts[:metadata_master].equal?(METADATA_MASTER_REQUEST) && opts[:use_master_key] == true
|
|
1369
|
+
|
|
1351
1370
|
if opts[:use_master_key] == false
|
|
1352
1371
|
headers[Parse::Middleware::Authentication::DISABLE_MASTER_KEY] = "true"
|
|
1353
1372
|
elsif Parse.client_mode && opts[:use_master_key] != true
|
|
@@ -1417,7 +1436,12 @@ module Parse
|
|
|
1417
1436
|
params = (method == :get ? query : body) || {}
|
|
1418
1437
|
# if the path does not start with the '/1/' prefix, then add it to be nice.
|
|
1419
1438
|
# actually send the request and return the body
|
|
1420
|
-
|
|
1439
|
+
send_headers = headers
|
|
1440
|
+
if metadata_master
|
|
1441
|
+
send_headers = headers.merge(Parse::Middleware::Authentication::METADATA_MASTER =>
|
|
1442
|
+
Parse::Middleware::Authentication::METADATA_MASTER_TOKEN)
|
|
1443
|
+
end
|
|
1444
|
+
response_env = @conn.send(method, uri, params, send_headers)
|
|
1421
1445
|
response = response_env.body
|
|
1422
1446
|
response.request = _request
|
|
1423
1447
|
|
data/lib/parse/clp_scope.rb
CHANGED
|
@@ -86,7 +86,14 @@ module Parse
|
|
|
86
86
|
# at request rate.
|
|
87
87
|
NEGATIVE_TTL = 5
|
|
88
88
|
|
|
89
|
+
# How long a schema fetch that failed inside `Parse.without_master_key`
|
|
90
|
+
# is remembered. Kept apart from the shared cache and consulted only
|
|
91
|
+
# inside the block, so it never denies another caller's reads; it only
|
|
92
|
+
# stops a down server from being refetched on every read in the block.
|
|
93
|
+
SUPPRESSED_NEGATIVE_TTL = 2
|
|
94
|
+
|
|
89
95
|
@cache = {}
|
|
96
|
+
@suppressed_failures = {}
|
|
90
97
|
@cache_mutex = Mutex.new
|
|
91
98
|
@cache_ttl = POSITIVE_TTL
|
|
92
99
|
|
|
@@ -490,7 +497,10 @@ module Parse
|
|
|
490
497
|
end
|
|
491
498
|
|
|
492
499
|
def reset_cache!
|
|
493
|
-
@cache_mutex.synchronize
|
|
500
|
+
@cache_mutex.synchronize do
|
|
501
|
+
@cache.clear
|
|
502
|
+
@suppressed_failures.clear
|
|
503
|
+
end
|
|
494
504
|
# Also drop the unresolvable-class warned-once registry so
|
|
495
505
|
# tests that assert on `warn` emission for a class don't get
|
|
496
506
|
# silenced by an earlier test's call.
|
|
@@ -599,6 +609,12 @@ module Parse
|
|
|
599
609
|
cached = @cache_mutex.synchronize { @cache[key] }
|
|
600
610
|
return cached if cached && !stale?(cached)
|
|
601
611
|
|
|
612
|
+
suppressed = Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
|
|
613
|
+
if suppressed
|
|
614
|
+
failed_at = @cache_mutex.synchronize { @suppressed_failures[key] }
|
|
615
|
+
return unresolvable_entry if failed_at && monotonic_now - failed_at < SUPPRESSED_NEGATIVE_TTL
|
|
616
|
+
end
|
|
617
|
+
|
|
602
618
|
entry = if resolved_client.nil?
|
|
603
619
|
# No client configured (Parse.setup never called, etc.) —
|
|
604
620
|
# treat as unresolvable so we fail closed instead of
|
|
@@ -620,7 +636,17 @@ module Parse
|
|
|
620
636
|
end
|
|
621
637
|
end
|
|
622
638
|
|
|
623
|
-
|
|
639
|
+
# A failure inside `Parse.without_master_key` is not put in the
|
|
640
|
+
# shared cache. The request keeps the master key there, but a custom
|
|
641
|
+
# schema source may not, and a negative entry is shared by every
|
|
642
|
+
# caller of this class for {NEGATIVE_TTL}: one block must not deny
|
|
643
|
+
# every other request's scoped reads. It is remembered briefly in a
|
|
644
|
+
# separate table read only inside the block.
|
|
645
|
+
if entry.kind == :unresolvable && suppressed
|
|
646
|
+
@cache_mutex.synchronize { @suppressed_failures[key] = monotonic_now }
|
|
647
|
+
else
|
|
648
|
+
@cache_mutex.synchronize { @cache[key] = entry }
|
|
649
|
+
end
|
|
624
650
|
entry
|
|
625
651
|
end
|
|
626
652
|
|
|
@@ -637,6 +663,13 @@ module Parse
|
|
|
637
663
|
# and the client-bound session token. A client that holds no master key
|
|
638
664
|
# still cannot read schemas, which stays fail-closed.
|
|
639
665
|
#
|
|
666
|
+
# The schema is metadata, so the request also carries the SDK's
|
|
667
|
+
# metadata marker: it keeps the master key inside
|
|
668
|
+
# `Parse.without_master_key`, which strips it from every other request
|
|
669
|
+
# in the block. Without it the fetch was refused there and the
|
|
670
|
+
# `:unresolvable` entry denied every scoped read of the class, for all
|
|
671
|
+
# callers, until it expired.
|
|
672
|
+
#
|
|
640
673
|
# Objects that only implement `#schema` (test doubles, custom schema
|
|
641
674
|
# sources installed via {.schema_client}), and clients whose `#schema`
|
|
642
675
|
# was overridden on the instance, keep the old call.
|
|
@@ -649,7 +682,8 @@ module Parse
|
|
|
649
682
|
if resolved_client.is_a?(Parse::Client) && stock_schema
|
|
650
683
|
safe = Parse::API::PathSegment.identifier!(class_key, kind: "class name")
|
|
651
684
|
resolved_client.request(:get, "schemas/#{safe}",
|
|
652
|
-
opts: { cache: false, use_master_key: true
|
|
685
|
+
opts: { cache: false, use_master_key: true,
|
|
686
|
+
metadata_master: Parse::Client::METADATA_MASTER_REQUEST })
|
|
653
687
|
else
|
|
654
688
|
resolved_client.schema(class_key)
|
|
655
689
|
end
|