parse-stack-next 5.7.6 → 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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -8,6 +8,7 @@ require_relative "atlas_search/index_manager"
8
8
  require_relative "atlas_search/search_builder"
9
9
  require_relative "atlas_search/result"
10
10
  require_relative "atlas_search/session"
11
+ require_relative "atlas_search/protected_paths"
11
12
 
12
13
  module Parse
13
14
  # Atlas Search module for MongoDB Atlas full-text search capabilities.
@@ -363,6 +364,7 @@ module Parse
363
364
 
364
365
  index_name = options[:index] || @default_index
365
366
  fields = normalize_fields(options[:fields])
367
+ assert_search_fields_allowed!(fields, protected_fields, resolution)
366
368
  limit = options[:limit] || 100
367
369
  skip_val = options[:skip] || 0
368
370
 
@@ -436,6 +438,15 @@ module Parse
436
438
  collection_name, resolution.permission_strings,
437
439
  )
438
440
  assert_highlight_field_allowed!(options[:highlight_field], protected_fields, resolution)
441
+ # The caller built this stage, so any operator path, sort key, or
442
+ # highlight inside it may name a protected field. Walk it and
443
+ # refuse before it runs: a protected field that decides matches
444
+ # or ranking leaks its value even though the output strips it.
445
+ ProtectedPaths.assert_search_stage_allowed!(
446
+ search_stage, protected_fields, resolution,
447
+ collection_name: collection_name,
448
+ method_name: "Parse::AtlasSearch.search_with_stage",
449
+ )
439
450
 
440
451
  search_pipeline!(
441
452
  collection_name, search_stage,
@@ -512,7 +523,8 @@ module Parse
512
523
  collection_name, resolution.permission_strings,
513
524
  )
514
525
  field_str = field.to_s
515
- if !resolution.master? && protected_fields.include?(field_str)
526
+ if ProtectedPaths.enforce?(resolution, protected_fields) &&
527
+ ProtectedPaths.touches?(field_str, protected_fields)
516
528
  raise Parse::CLPScope::Denied.new(
517
529
  collection_name, :find,
518
530
  "Parse::AtlasSearch.autocomplete refused: field '#{field_str}' is in " \
@@ -547,6 +559,7 @@ module Parse
547
559
  unless resolution.master?
548
560
  acl_match = Parse::ACLScope.match_stage_for(resolution)
549
561
  pipeline << acl_match if acl_match
562
+ pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
550
563
  end
551
564
 
552
565
  # Add filter if provided
@@ -648,6 +661,17 @@ module Parse
648
661
  "accept that bucket counts include all rows, or use " \
649
662
  "#search for ACL-scoped results without facets."
650
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
651
675
  # Wave-3b READPREF-4: see #search for rationale. Captured
652
676
  # before resolve_scope! pops the auth kwargs so the recursive
653
677
  # search() call below can re-thread it explicitly (resolve!
@@ -689,6 +713,21 @@ module Parse
689
713
  end
690
714
  end
691
715
 
716
+ # A non-master caller (the public fallback when no auth kwargs are
717
+ # passed) must not facet on, or text-match against, a protected
718
+ # field: bucket values and counts would reveal it directly, and a
719
+ # wildcard operator lets it decide which rows are counted.
720
+ unless resolution.master?
721
+ facet_protected = Parse::CLPScope.protected_fields_for(
722
+ collection_name, resolution.permission_strings,
723
+ )
724
+ ProtectedPaths.assert_search_stage_allowed!(
725
+ search_meta_stage, facet_protected, resolution,
726
+ collection_name: collection_name,
727
+ method_name: "Parse::AtlasSearch.faceted_search",
728
+ )
729
+ end
730
+
692
731
  # Execute facet query. $searchMeta MUST be the only / first
693
732
  # stage of its pipeline — Atlas rejects anything prepended.
694
733
  # Bypass Parse::MongoDB.aggregate (which would prepend a
@@ -788,6 +827,7 @@ module Parse
788
827
  unless resolution.master?
789
828
  acl_match = Parse::ACLScope.match_stage_for(resolution)
790
829
  pipeline << acl_match if acl_match
830
+ pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
791
831
  end
792
832
 
793
833
  # Caller-supplied filter, sanitized against operator injection
@@ -797,6 +837,14 @@ module Parse
797
837
  pipeline << { "$match" => mongo_filter }
798
838
  end
799
839
 
840
+ # Sorting on a protected field orders rows by its value, which is
841
+ # the same oracle as matching on it.
842
+ if sort.is_a?(Hash) && !sort.empty?
843
+ ProtectedPaths.assert_paths_allowed!(
844
+ sort.keys.map(&:to_s), protected_fields, resolution,
845
+ collection_name: collection_name, what: "sort key",
846
+ )
847
+ end
800
848
  pipeline << { "$sort" => (sort || { "_score" => -1 }) }
801
849
  pipeline << { "$skip" => skip } if skip.to_i > 0
802
850
  pipeline << { "$limit" => limit }
@@ -816,6 +864,8 @@ module Parse
816
864
  unless resolution.master?
817
865
  Parse::ACLScope.redact_results!(raw_results, resolution)
818
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.
819
869
  if pointer_fields
820
870
  raw_results = Parse::CLPScope.filter_by_pointer_fields(
821
871
  raw_results, pointer_fields, resolution.user_id,
@@ -937,13 +987,37 @@ module Parse
937
987
  return Parse::ACLScope.resolve_for_role(acl_role, client: auth_client)
938
988
  end
939
989
 
940
- if master == true
990
+ if master == true && !Parse::ACLScope.master_key_suppressed?
941
991
  return Parse::ACLScope::Resolution.new(
942
992
  mode: :master, permission_strings: nil, user_id: nil, session: nil,
943
993
  client: auth_client,
944
994
  )
945
995
  end
946
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
+
947
1021
  if @require_session_token == true
948
1022
  raise ACLRequired,
949
1023
  "Parse::AtlasSearch.#{method_name} requires session_token: or " \
@@ -980,13 +1054,12 @@ module Parse
980
1054
  # does inline (we can't reuse that path because of the $search-
981
1055
  # at-stage-0 invariant).
982
1056
  def assert_clp_find!(collection_name, resolution)
983
- return if resolution.nil? || resolution.master?
984
- unless Parse::CLPScope.permits?(collection_name, :find, resolution.permission_strings)
985
- raise Parse::CLPScope::Denied.new(
986
- collection_name, :find,
987
- "CLP refuses find on '#{collection_name}' for the current Atlas Search scope.",
988
- )
989
- end
1057
+ # Same CLP branch evaluation as Parse::MongoDB.aggregate (public,
1058
+ # user, and role grants first; then pointerFields / readUserFields).
1059
+ # Raises Parse::CLPScope::Denied when the scope cannot find at all.
1060
+ Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
1061
+ label: "Atlas Search")
1062
+ nil
990
1063
  end
991
1064
 
992
1065
  # Resolve and return pointerFields for `find` on the collection.
@@ -994,17 +1067,22 @@ module Parse
994
1067
  # current scope has no user_id (acl_role-only / public agents).
995
1068
  # Returns nil when master-mode or no pointerFields entry exists.
996
1069
  def resolve_pointer_fields!(collection_name, resolution)
997
- return nil if resolution.nil? || resolution.master?
998
- pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
999
- return nil if pointer_fields.nil?
1000
- if resolution.user_id.nil?
1001
- raise Parse::CLPScope::Denied.new(
1002
- collection_name, :find,
1003
- "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
1004
- "but the current Atlas Search scope has no user_id.",
1005
- )
1006
- end
1007
- pointer_fields
1070
+ # nil when a public, user, or role grant already permits every row
1071
+ # (Parse Server ignores pointer permissions then); otherwise the
1072
+ # pointerFields plus readUserFields the rows must match. The older
1073
+ # permits? / pointer_fields_for pair missed readUserFields entirely
1074
+ # and over-restricted a public grant that also listed pointerFields.
1075
+ Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
1076
+ label: "Atlas Search")
1077
+ end
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) }
1008
1086
  end
1009
1087
 
1010
1088
  # ATLAS-4: refuse `highlight_field:` when the field is in the
@@ -1016,8 +1094,8 @@ module Parse
1016
1094
  return if highlight_field.nil?
1017
1095
  return if resolution.nil? || resolution.master?
1018
1096
  return if protected_fields.nil? || protected_fields.empty?
1019
- path = highlight_field.to_s
1020
- return unless protected_fields.include?(path)
1097
+ return unless ProtectedPaths.touches?(highlight_field, protected_fields)
1098
+ path = highlight_field.is_a?(String) || highlight_field.is_a?(Symbol) ? highlight_field.to_s : highlight_field.inspect
1021
1099
  raise Parse::CLPScope::Denied.new(
1022
1100
  nil, :find,
1023
1101
  "Parse::AtlasSearch.search refused: highlight_field '#{path}' is in " \
@@ -1026,6 +1104,35 @@ module Parse
1026
1104
  )
1027
1105
  end
1028
1106
 
1107
+ # Refuse a scoped text search whose paths include a protected field,
1108
+ # or that searches every field (no `fields:`) while the scope has
1109
+ # protected fields. Stripping a protected field from the RESULT does
1110
+ # not stop it from deciding which documents MATCH and how they RANK,
1111
+ # so a caller could test guesses against its value. Master scopes and
1112
+ # scopes with nothing protected are unaffected.
1113
+ #
1114
+ # @raise [Parse::CLPScope::Denied]
1115
+ def assert_search_fields_allowed!(fields, protected_fields, resolution)
1116
+ return if resolution.nil? || resolution.master?
1117
+ return if protected_fields.nil? || protected_fields.empty?
1118
+ if fields.nil? || fields.empty?
1119
+ raise Parse::CLPScope::Denied.new(
1120
+ nil, :find,
1121
+ "Parse::AtlasSearch.search refused: a search over every field would " \
1122
+ "match on protectedFields for the current scope; pass fields: with " \
1123
+ "the fields to search.",
1124
+ )
1125
+ end
1126
+ list = fields.is_a?(Array) ? fields : [fields]
1127
+ hit = list.find { |f| ProtectedPaths.touches?(f, protected_fields) }
1128
+ return unless hit
1129
+ raise Parse::CLPScope::Denied.new(
1130
+ nil, :find,
1131
+ "Parse::AtlasSearch.search refused: field '#{hit}' is in protectedFields " \
1132
+ "for the current scope; matching on it would reveal its value.",
1133
+ )
1134
+ end
1135
+
1029
1136
  # Drop `_highlights` entries whose `path` matches a
1030
1137
  # protectedFields entry. Defense-in-depth complement to
1031
1138
  # {.assert_highlight_field_allowed!} — that gate refuses the
@@ -1035,13 +1142,14 @@ module Parse
1035
1142
  def strip_protected_highlights!(documents, protected_fields)
1036
1143
  return if documents.nil? || documents.empty?
1037
1144
  return if protected_fields.nil? || protected_fields.empty?
1038
- protected_set = protected_fields.to_set
1039
1145
  documents.each do |doc|
1040
1146
  next unless doc.is_a?(Hash)
1041
1147
  highlights = doc["_highlights"]
1042
1148
  next unless highlights.is_a?(Array)
1043
1149
  doc["_highlights"] = highlights.reject do |h|
1044
- h.is_a?(Hash) && protected_set.include?((h["path"] || h[:path]).to_s)
1150
+ # A highlight entry with no usable path is dropped too: it
1151
+ # cannot be shown to address an unprotected field.
1152
+ h.is_a?(Hash) && ProtectedPaths.touches?(h["path"] || h[:path], protected_fields)
1045
1153
  end
1046
1154
  end
1047
1155
  end
@@ -1172,6 +1280,18 @@ module Parse
1172
1280
  Parse::PipelineSecurity.refuse_protected_field_references!(
1173
1281
  [{ "$match" => filter }], collection_name, resolution,
1174
1282
  )
1283
+ # Predicate KEYS decide matches too: `{ "ssn" => /^1/ }` is the
1284
+ # same oracle as the `$expr` form above. Refuse any key (top
1285
+ # level or nested under $and/$or/$nor/$not, dotted or `_p_`
1286
+ # storage form) that touches a protected field.
1287
+ unless resolution.master?
1288
+ protected_fields = Parse::CLPScope.protected_fields_for(
1289
+ collection_name, resolution.permission_strings,
1290
+ )
1291
+ ProtectedPaths.assert_filter_allowed!(
1292
+ filter, protected_fields, resolution, collection_name: collection_name,
1293
+ )
1294
+ end
1175
1295
  end
1176
1296
 
1177
1297
  filter
@@ -97,12 +97,68 @@ module Parse
97
97
  @mutex.synchronize { @data.delete(key) }
98
98
  end
99
99
 
100
+ # Drop every entry whose value equals `value`. Lets the identity plane
101
+ # forget every token of one user after a revocation, since it is keyed
102
+ # by token and holds the user id as the value.
103
+ # @param value [Object]
104
+ # @return [Integer] number of entries removed.
105
+ def invalidate_value(value)
106
+ @mutex.synchronize do
107
+ before = @data.size
108
+ @data.delete_if { |_key, entry| entry[:value] == value }
109
+ before - @data.size
110
+ end
111
+ end
112
+
100
113
  # Drop every entry.
101
114
  def clear
102
115
  @mutex.synchronize { @data.clear }
103
116
  end
104
117
  end
105
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
+
106
162
  # The outcome of resolving a caller. `user_id` is the `_User.objectId`
107
163
  # owning the session, or `nil` for an anonymous caller. `role_names` is a
108
164
  # `Set` of bare role names (no `role:` prefix) the user inherits
@@ -176,8 +232,26 @@ module Parse
176
232
  @role_cache_ttl = DEFAULT_ROLE_TTL
177
233
  @upstream_role_reader = nil
178
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
179
244
  end
180
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
+
181
255
  # Apply settings, leaving anything not passed unchanged.
182
256
  # @return [self]
183
257
  def configure(identity_cache: nil, role_cache: nil,
@@ -233,9 +307,40 @@ module Parse
233
307
  # @param session_token [String]
234
308
  def invalidate(session_token)
235
309
  return if session_token.nil?
310
+ bump_invalidation_epoch!
236
311
  @identity_cache.invalidate(session_token.to_s)
237
312
  end
238
313
 
314
+ # Forget every cached identity entry that resolves to `user_id`. Call
315
+ # after an event that revokes the user's sessions: a password change,
316
+ # account deletion, a session destroy, or "log out everywhere". The SDK
317
+ # calls it itself on those paths.
318
+ #
319
+ # The identity plane is keyed by token, so there is no direct way to
320
+ # name a user's entries. A generation-capable plane (the keyspaced
321
+ # Redis identity plane) bumps the user's generation, which rejects
322
+ # every entry for that user, including tokens this process never
323
+ # resolved. The default {MemoryCache} drops the matching values. A
324
+ # custom plane that supports neither is left to its TTL.
325
+ #
326
+ # The role entry is dropped too: it is cheap to rebuild and a deleted
327
+ # user should not keep a role closure around.
328
+ # @param user_id [String]
329
+ # @return [void]
330
+ def invalidate_user(user_id)
331
+ return if user_id.nil? || user_id.to_s.empty?
332
+ uid = user_id.to_s
333
+ bump_invalidation_epoch!
334
+ cache = @identity_cache
335
+ if generation_capable?(cache) && cache.respond_to?(:bump_generation)
336
+ cache.bump_generation(uid)
337
+ elsif cache.respond_to?(:invalidate_value)
338
+ cache.invalidate_value(uid)
339
+ end
340
+ @role_cache.invalidate(uid)
341
+ nil
342
+ end
343
+
239
344
  # Forget one user's cached role closure. Call after any `_Role.users`
240
345
  # mutation affecting them.
241
346
  # @param user_id [String]
@@ -244,10 +349,50 @@ module Parse
244
349
  @role_cache.invalidate(user_id.to_s)
245
350
  end
246
351
 
352
+ # Forget every cached role closure. Call after a `_Role.roles` hierarchy
353
+ # change, which can affect any user holding a role in that hierarchy.
354
+ def invalidate_all_roles
355
+ @role_cache.clear if @role_cache.respond_to?(:clear)
356
+ end
357
+
247
358
  # Drop every entry in both planes.
248
359
  def reset_caches!
360
+ bump_invalidation_epoch!
249
361
  @identity_cache.clear if @identity_cache.respond_to?(:clear)
250
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
251
396
  end
252
397
 
253
398
  def inspect
@@ -266,8 +411,21 @@ module Parse
266
411
  cached = cached_user_id(session_token)
267
412
  return cached unless cached.nil?
268
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
+
269
422
  response = begin
270
- @client.current_user(session_token)
423
+ # cache: false: a revoked or expired token must not re-resolve
424
+ # from a cached /users/me response after its identity entry is
425
+ # evicted or invalidated. The identity plane above is the only
426
+ # cache on this path, so its TTL and invalidation hooks bound
427
+ # revocation.
428
+ @client.current_user(session_token, cache: false)
271
429
  rescue => e
272
430
  raise InvalidSession, "session token lookup failed: #{e.class}: #{e.message}"
273
431
  end
@@ -278,10 +436,136 @@ module Parse
278
436
  raise InvalidSession, "session token resolved no user objectId" if user_id.nil? || user_id.to_s.empty?
279
437
 
280
438
  user_id = user_id.to_s
281
- 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)
282
444
  user_id
283
445
  end
284
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
+
285
569
  # Read the identity plane and, where the plane supports it, check that
286
570
  # the entry's generation is still current.
287
571
  #
@@ -316,10 +600,11 @@ module Parse
316
600
 
317
601
  # Write the identity entry, tagging it with the subject's current
318
602
  # generation when the plane can track one.
319
- def store_user_id(session_token, user_id)
603
+ def store_user_id(session_token, user_id, gen: nil)
320
604
  cache = @identity_cache
321
605
  if generation_capable?(cache)
322
- 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 },
323
608
  ttl: @identity_cache_ttl)
324
609
  else
325
610
  cache.set(session_token, user_id, ttl: @identity_cache_ttl)