parse-stack-next 5.8.0 → 5.8.2

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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -1
@@ -19,13 +19,81 @@ module Parse
19
19
  # @overload batch_request(operation)
20
20
  # Submit a batch operation.
21
21
  # @param operation [Parse::BatchOperation] the batch operation.
22
+ # @param opts [Hash] request options for the `POST /batch` call itself,
23
+ # such as `session_token:` or `use_master_key:`. Parse Server runs
24
+ # every sub-request under the credentials of this one call (it ignores
25
+ # per-sub-request headers), so the batch's authority is set here. A
26
+ # request that names no credentials of its own runs under these.
27
+ # Narrowing options always win: `use_master_key: false` here keeps
28
+ # the master key off even for a request that set
29
+ # `use_master_key: true`. An option here never widens a request.
30
+ # @note Every request is sent through this client, as
31
+ # {Parse::Client#request} sends a single request through the client
32
+ # it is called on, even when the request was built for another
33
+ # object's class client.
34
+ # @note Each {Parse::Request}'s own credentials (its `session_token:` /
35
+ # `use_master_key:` options and its session-token or master-key
36
+ # suppression headers) are honored.
37
+ # When every request resolves to this client and one set of
38
+ # credentials, the batch is one call. A transaction that would mix
39
+ # credentials raises {Parse::BatchOperation::MixedAuthorityError}
40
+ # before anything is sent. A non-transactional batch with mixed
41
+ # credentials is sent as one call per set of credentials, through
42
+ # {Parse::BatchOperation#submit}, with responses in request order.
43
+ # Elements that are not {Parse::Request} instances are ignored, as
44
+ # {Parse::BatchOperation#add} ignores them.
22
45
  # @return [Array<Parse::Response>] if successful, a set of responses for each operation in the batch.
23
46
  # @return [Parse::Response] if an error occurred, the error response.
24
- def batch_request(batch_operations)
47
+ # @raise [Parse::BatchOperation::MixedAuthorityError] for a transaction
48
+ # whose requests name different credentials.
49
+ def batch_request(batch_operations, **opts)
25
50
  unless batch_operations.is_a?(Parse::BatchOperation)
26
51
  batch_operations = Parse::BatchOperation.new batch_operations
27
52
  end
28
- response = request(:post, "batch", body: batch_operations.as_json)
53
+ call_auth = opts.slice(:session_token, :use_master_key, :suppress_master_key)
54
+ groups = batch_operations.send(:authority_groups, self, call_auth, force_client: true)
55
+ if groups.empty?
56
+ return post_batch_operation(batch_operations, opts.except(:suppress_master_key))
57
+ end
58
+ if groups.size == 1 &&
59
+ Parse::BatchOperation.credential_fingerprint(groups.first[:client]) ==
60
+ Parse::BatchOperation.credential_fingerprint(self)
61
+ send_opts = opts.except(:session_token, :use_master_key, :suppress_master_key)
62
+ .merge(groups.first[:opts])
63
+ return post_batch_operation(batch_operations, send_opts)
64
+ end
65
+ if batch_operations.transaction
66
+ raise Parse::BatchOperation::MixedAuthorityError,
67
+ "A transaction cannot mix requests built for different credentials or " \
68
+ "another client. Parse Server runs a batch under one credential, so it " \
69
+ "cannot honor each request's own. Save these objects in separate transactions."
70
+ end
71
+ # Mixed credentials, not a transaction: send one call per set of
72
+ # credentials, keeping responses in request order.
73
+ # Assigned, not re-added: `add` would drop tagged duplicates and leave
74
+ # fewer responses than requests.
75
+ routed = Parse::BatchOperation.new
76
+ routed.requests = batch_operations.requests.dup
77
+ routed.client = self
78
+ routed.batch_defaults = call_auth
79
+ routed.submit
80
+ end
81
+
82
+ private
83
+
84
+ # Send one `POST /batch` call and map its result to responses.
85
+ # @param batch_operations [Parse::BatchOperation]
86
+ # @param opts [Hash] request options for the call.
87
+ def post_batch_operation(batch_operations, opts)
88
+ opts = opts.dup
89
+ headers = nil
90
+ # Sent as the header itself, exactly as a single request carries it:
91
+ # the authentication middleware drops the master key when it is set,
92
+ # even with `use_master_key: true`.
93
+ if opts.delete(:suppress_master_key)
94
+ headers = { Parse::Middleware::Authentication::DISABLE_MASTER_KEY => "true" }
95
+ end
96
+ response = request(:post, "batch", body: batch_operations.as_json, headers: headers, opts: opts)
29
97
  return response.batch_responses if response.success? && response.batch?
30
98
  return response if response.error?
31
99
  # A successful HTTP response whose body is not an array of results
@@ -65,7 +65,11 @@ module Parse
65
65
  # `master: true` so the SDK's CLP layer skips this metadata
66
66
  # pipeline. The mongo-side privilege check still applies
67
67
  # (the underlying connection must hold `listSearchIndexes`).
68
- results = Parse::MongoDB.aggregate(collection_name, pipeline, master: true)
68
+ # The metadata sentinel keeps that master mode inside a
69
+ # `Parse.without_master_key` block, which governs row access,
70
+ # without re-enabling the master key for anything else.
71
+ results = Parse::MongoDB.aggregate(collection_name, pipeline,
72
+ master: Parse::ACLScope::METADATA_MASTER)
69
73
  cache_mutex.synchronize { cache_indexes(collection_name, results) }
70
74
  results
71
75
  rescue => e
@@ -559,6 +559,7 @@ module Parse
559
559
  unless resolution.master?
560
560
  acl_match = Parse::ACLScope.match_stage_for(resolution)
561
561
  pipeline << acl_match if acl_match
562
+ pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
562
563
  end
563
564
 
564
565
  # Add filter if provided
@@ -660,6 +661,17 @@ module Parse
660
661
  "accept that bucket counts include all rows, or use " \
661
662
  "#search for ACL-scoped results without facets."
662
663
  end
664
+ # Inside Parse.without_master_key the only scope left is public,
665
+ # and $searchMeta bucket counts are not ACL-filtered there either,
666
+ # so they would count rows the block is meant to hide. Fail closed.
667
+ if Parse::ACLScope.master_key_suppressed?
668
+ raise FacetedSearchNotACLSafe,
669
+ "Parse::AtlasSearch.faceted_search cannot run inside " \
670
+ "Parse.without_master_key: $searchMeta bucket counts include " \
671
+ "rows the caller cannot read. Wrap the call in " \
672
+ "Parse.with_master_key to accept master-key counts, or use " \
673
+ "#search for ACL-scoped results without facets."
674
+ end
663
675
  # Wave-3b READPREF-4: see #search for rationale. Captured
664
676
  # before resolve_scope! pops the auth kwargs so the recursive
665
677
  # search() call below can re-thread it explicitly (resolve!
@@ -815,6 +827,7 @@ module Parse
815
827
  unless resolution.master?
816
828
  acl_match = Parse::ACLScope.match_stage_for(resolution)
817
829
  pipeline << acl_match if acl_match
830
+ pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
818
831
  end
819
832
 
820
833
  # Caller-supplied filter, sanitized against operator injection
@@ -851,6 +864,8 @@ module Parse
851
864
  unless resolution.master?
852
865
  Parse::ACLScope.redact_results!(raw_results, resolution)
853
866
  Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
867
+ # The pointerFields `$match` above already ran before `$limit`;
868
+ # this re-check is defense in depth and should drop nothing.
854
869
  if pointer_fields
855
870
  raw_results = Parse::CLPScope.filter_by_pointer_fields(
856
871
  raw_results, pointer_fields, resolution.user_id,
@@ -972,13 +987,37 @@ module Parse
972
987
  return Parse::ACLScope.resolve_for_role(acl_role, client: auth_client)
973
988
  end
974
989
 
975
- if master == true
990
+ if master == true && !Parse::ACLScope.master_key_suppressed?
976
991
  return Parse::ACLScope::Resolution.new(
977
992
  mode: :master, permission_strings: nil, user_id: nil, session: nil,
978
993
  client: auth_client,
979
994
  )
980
995
  end
981
996
 
997
+ # Inside `Parse.without_master_key` an explicit master request runs
998
+ # in the public scope, as Parse::ACLScope.resolve! does: no
999
+ # no-ACL banner (the caller did pass a scope), and either strict
1000
+ # flag refuses the fallback.
1001
+ if master == true
1002
+ if @require_session_token == true || Parse::ACLScope.require_session_token == true
1003
+ raise ACLRequired,
1004
+ "Parse::AtlasSearch.#{method_name} was called with master: true " \
1005
+ "inside Parse.without_master_key, which drops master mode, and " \
1006
+ "require_session_token refuses the public fallback. Pass " \
1007
+ "session_token:, or run the call inside Parse.with_master_key " \
1008
+ "if it needs master authority."
1009
+ end
1010
+ anonymous = Session::Resolved.new(nil, Set.new)
1011
+ return Parse::ACLScope::Resolution.new(
1012
+ mode: :public,
1013
+ permission_strings: anonymous.permission_strings,
1014
+ user_id: nil,
1015
+ session: anonymous,
1016
+ client: auth_client,
1017
+ master_dropped: true,
1018
+ )
1019
+ end
1020
+
982
1021
  if @require_session_token == true
983
1022
  raise ACLRequired,
984
1023
  "Parse::AtlasSearch.#{method_name} requires session_token: or " \
@@ -1037,6 +1076,15 @@ module Parse
1037
1076
  label: "Atlas Search")
1038
1077
  end
1039
1078
 
1079
+ # The pointerFields / readUserFields ownership constraint as a
1080
+ # `$match` stage, placed after the ACL `$match` and before `$sort` /
1081
+ # `$limit`. Filtering the fetched rows afterwards returned short or
1082
+ # empty pages whenever the top-ranked hits belonged to other users,
1083
+ # even though eligible matches ranked below them.
1084
+ def pointer_fields_match_stage(pointer_fields, resolution)
1085
+ { "$match" => Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id) }
1086
+ end
1087
+
1040
1088
  # ATLAS-4: refuse `highlight_field:` when the field is in the
1041
1089
  # resolved protectedFields set. searchHighlights returns the
1042
1090
  # matched token plus surrounding chars verbatim; running it on
@@ -1120,6 +1168,7 @@ module Parse
1120
1168
  def run_atlas_pipeline!(collection_name, pipeline, max_time_ms = nil, read_preference: nil,
1121
1169
  authorizing_client: nil)
1122
1170
  agg_opts = {}
1171
+ max_time_ms ||= Parse::PipelineSecurity.regex_time_budget(pipeline)
1123
1172
  agg_opts[:max_time_ms] = max_time_ms if max_time_ms
1124
1173
  # Atlas Search does not go through Parse::MongoDB.aggregate, so this
1125
1174
  # is the only place its reads meet the binding guard.
@@ -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
- store_user_id(session_token, user_id)
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
- cache.set(session_token, { "user_id" => user_id, "gen" => cache.generation(user_id) },
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
- fiber_disable = Parse.master_key_disabled?
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