parse-stack-next 5.6.0 → 5.7.0

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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +409 -0
  3. data/README.md +378 -6
  4. data/docs/caching.md +748 -0
  5. data/examples/basic_client.rb +3 -3
  6. data/examples/basic_server.rb +3 -3
  7. data/examples/live_query_listener.rb +12 -12
  8. data/examples/rag_chatbot.rb +14 -14
  9. data/examples/transaction_example.rb +44 -45
  10. data/examples/webhook_server.rb +3 -3
  11. data/lib/parse/access.rb +493 -0
  12. data/lib/parse/acl_scope.rb +125 -52
  13. data/lib/parse/agent/approval_gate.rb +0 -0
  14. data/lib/parse/agent/cancellation_token.rb +3 -3
  15. data/lib/parse/agent/constraint_translator.rb +4 -4
  16. data/lib/parse/agent/describe.rb +34 -34
  17. data/lib/parse/agent/errors.rb +9 -9
  18. data/lib/parse/agent/mcp_client.rb +61 -59
  19. data/lib/parse/agent/mcp_dispatcher.rb +89 -101
  20. data/lib/parse/agent/mcp_rack_app.rb +92 -93
  21. data/lib/parse/agent/mcp_server.rb +1 -1
  22. data/lib/parse/agent/mcp_subscriptions.rb +22 -22
  23. data/lib/parse/agent/metadata_audit.rb +1 -2
  24. data/lib/parse/agent/metadata_dsl.rb +7 -7
  25. data/lib/parse/agent/metadata_registry.rb +12 -12
  26. data/lib/parse/agent/prompt_hardening.rb +4 -4
  27. data/lib/parse/agent/prompts.rb +10 -10
  28. data/lib/parse/agent/result_formatter.rb +4 -4
  29. data/lib/parse/agent/tools.rb +493 -400
  30. data/lib/parse/agent.rb +157 -116
  31. data/lib/parse/api/hooks.rb +1 -1
  32. data/lib/parse/api/server.rb +2 -2
  33. data/lib/parse/api/users.rb +2 -2
  34. data/lib/parse/atlas_search/index_manager.rb +1 -1
  35. data/lib/parse/atlas_search/session.rb +40 -218
  36. data/lib/parse/atlas_search.rb +181 -60
  37. data/lib/parse/authorization.rb +466 -0
  38. data/lib/parse/cache/invalidation.rb +167 -0
  39. data/lib/parse/cache/keyspace.rb +306 -0
  40. data/lib/parse/cache/moneta_surface.rb +126 -0
  41. data/lib/parse/cache/pool.rb +48 -5
  42. data/lib/parse/cache/redis.rb +425 -10
  43. data/lib/parse/cache/scoped_view.rb +443 -0
  44. data/lib/parse/cache/sub_cache.rb +264 -0
  45. data/lib/parse/cache/upstream_roles.rb +230 -0
  46. data/lib/parse/client/authentication.rb +1 -1
  47. data/lib/parse/client/body_builder.rb +1 -2
  48. data/lib/parse/client/caching.rb +84 -5
  49. data/lib/parse/client.rb +327 -189
  50. data/lib/parse/clp_scope.rb +225 -28
  51. data/lib/parse/console.rb +3 -3
  52. data/lib/parse/embeddings/batch_embedder.rb +3 -3
  53. data/lib/parse/embeddings/cache.rb +17 -17
  54. data/lib/parse/embeddings/cohere.rb +21 -22
  55. data/lib/parse/embeddings/image_fetch.rb +22 -21
  56. data/lib/parse/embeddings/jina.rb +21 -21
  57. data/lib/parse/embeddings/local_http.rb +6 -7
  58. data/lib/parse/embeddings/media_file.rb +5 -4
  59. data/lib/parse/embeddings/openai.rb +5 -5
  60. data/lib/parse/embeddings/provider.rb +1 -1
  61. data/lib/parse/embeddings/qwen.rb +10 -10
  62. data/lib/parse/embeddings/spend_cap.rb +2 -3
  63. data/lib/parse/embeddings/streaming_body.rb +11 -11
  64. data/lib/parse/embeddings/video_source.rb +7 -6
  65. data/lib/parse/embeddings/voyage.rb +59 -61
  66. data/lib/parse/embeddings.rb +28 -27
  67. data/lib/parse/graphql/type_generator.rb +1 -1
  68. data/lib/parse/graphql.rb +5 -5
  69. data/lib/parse/live_query/client.rb +1 -1
  70. data/lib/parse/live_query.rb +1 -1
  71. data/lib/parse/lock.rb +15 -18
  72. data/lib/parse/lock_backend.rb +1 -1
  73. data/lib/parse/lookup_rewriter.rb +6 -6
  74. data/lib/parse/model/classes/role.rb +227 -28
  75. data/lib/parse/model/classes/user.rb +60 -1
  76. data/lib/parse/model/clp.rb +4 -4
  77. data/lib/parse/model/core/actions.rb +97 -15
  78. data/lib/parse/model/core/create_lock.rb +0 -2
  79. data/lib/parse/model/core/describe.rb +57 -57
  80. data/lib/parse/model/core/embed_managed.rb +21 -23
  81. data/lib/parse/model/core/fetching.rb +2 -0
  82. data/lib/parse/model/core/indexing.rb +14 -14
  83. data/lib/parse/model/core/parse_reference.rb +1 -1
  84. data/lib/parse/model/core/properties.rb +2 -2
  85. data/lib/parse/model/core/querying.rb +1 -1
  86. data/lib/parse/model/core/schema.rb +2 -2
  87. data/lib/parse/model/core/search_indexing.rb +2 -2
  88. data/lib/parse/model/core/vector_searchable.rb +4 -5
  89. data/lib/parse/model/file.rb +21 -15
  90. data/lib/parse/model/geojson.rb +2 -2
  91. data/lib/parse/model/geopoint.rb +1 -0
  92. data/lib/parse/model/object.rb +132 -66
  93. data/lib/parse/model/pointer.rb +2 -0
  94. data/lib/parse/model/polygon.rb +3 -6
  95. data/lib/parse/model/push.rb +2 -2
  96. data/lib/parse/model/vector.rb +3 -1
  97. data/lib/parse/mongodb.rb +512 -282
  98. data/lib/parse/pipeline_security.rb +3 -0
  99. data/lib/parse/query/constraints.rb +46 -46
  100. data/lib/parse/query.rb +146 -59
  101. data/lib/parse/retrieval/agent_tool.rb +34 -20
  102. data/lib/parse/retrieval/chunk.rb +1 -0
  103. data/lib/parse/retrieval/reranker/cohere.rb +11 -11
  104. data/lib/parse/retrieval/reranker.rb +3 -4
  105. data/lib/parse/retrieval/retriever.rb +4 -6
  106. data/lib/parse/schema/index_migrator.rb +26 -26
  107. data/lib/parse/schema/search_index_migrator.rb +19 -19
  108. data/lib/parse/stack/tasks.rb +6 -6
  109. data/lib/parse/stack/version.rb +1 -1
  110. data/lib/parse/stack.rb +10 -10
  111. data/lib/parse/vector_search/hybrid.rb +66 -41
  112. data/lib/parse/vector_search.rb +14 -11
  113. data/lib/parse/webhooks/payload.rb +1 -0
  114. data/lib/parse/webhooks/registration.rb +4 -4
  115. data/lib/parse/webhooks/trigger_audit.rb +50 -49
  116. data/lib/parse/webhooks.rb +22 -4
  117. data/parse-stack-next.gemspec +6 -6
  118. metadata +10 -1
@@ -0,0 +1,264 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "set"
6
+
7
+ module Parse
8
+ module Cache
9
+ # A named plane within a {Parse::Cache::Keyspace}, exposing the
10
+ # `get` / `set` / `invalidate` contract that `Parse::AtlasSearch`'s
11
+ # `session_cache=` and `role_cache=` slots accept.
12
+ #
13
+ # The response cache speaks Moneta (`[]`, `key?`, `delete`, `store`) because
14
+ # that is what the Faraday middleware requires. Identity and role resolution
15
+ # speak a different, smaller contract. Rather than flattening both onto one
16
+ # object, where `get` and `[]` would sit side by side and `clear` would be
17
+ # ambiguous about which plane it cleared, each plane is its own object over
18
+ # the same connection. This mirrors Parse Server's own
19
+ # `CacheController` / `SubCache` split.
20
+ class SubCache
21
+ # @return [Symbol] the keyspace family this plane writes.
22
+ attr_reader :family
23
+
24
+ # @param store [Object] the backing store (a {Parse::Cache::Redis}).
25
+ # @param keyspace [Parse::Cache::Keyspace] key layout owner.
26
+ # @param family [Symbol] `:idn` or `:role`.
27
+ # @param ttl [Integer, nil] default TTL in seconds.
28
+ # @param digest_keys [Boolean] hash the logical key before it becomes part
29
+ # of a Redis key. Required for the identity plane: its logical key is a
30
+ # raw session token supplied by `Parse::AtlasSearch::Session`, and a raw
31
+ # token must never be written into a key, where MONITOR, SLOWLOG, and
32
+ # APM key capture would all expose it. Digesting here rather than at the
33
+ # call site also keeps `get`, `set`, and `invalidate` consistent by
34
+ # construction: a caller that digested for one and not another would
35
+ # silently delete a different key than it wrote.
36
+ # Generation keys live this many times longer than the longest entry
37
+ # they guard. Any factor above 1 is sufficient; 2 leaves margin for
38
+ # clock skew and for an entry written moments before a bump.
39
+ GENERATION_TTL_FACTOR = 2
40
+
41
+ def initialize(store:, keyspace:, family:, ttl: nil, digest_keys: nil)
42
+ @store = store
43
+ @keyspace = keyspace
44
+ @family = family.to_sym
45
+ @ttl = ttl
46
+ @digest_keys = digest_keys.nil? ? @family == :idn : digest_keys
47
+ end
48
+
49
+ # @return [Integer, nil] how long a generation key lives, or nil when
50
+ # this plane has no default TTL and generations must therefore be
51
+ # permanent. See {#bump_generation} for why the two are tied.
52
+ def generation_ttl
53
+ return nil if @ttl.nil?
54
+ (@ttl * GENERATION_TTL_FACTOR).ceil
55
+ end
56
+
57
+ # @param key [String] the logical key (a token digest or user id).
58
+ # @return [Object, nil] the stored value, or nil on a miss.
59
+ def get(key)
60
+ return nil if key.nil?
61
+ decode(@store[@keyspace.key(@family, logical_key(key))])
62
+ end
63
+
64
+ # @param key [String] the logical key.
65
+ # @param value [Object] a JSON-serializable value.
66
+ # @param ttl [Integer, nil] seconds; falls back to the plane default.
67
+ def set(key, value, ttl: nil)
68
+ return value if key.nil?
69
+ effective = clamp_ttl(ttl || @ttl)
70
+ options = effective ? { expires: effective } : {}
71
+ @store.store(@keyspace.key(@family, logical_key(key)), encode(value), options)
72
+ value
73
+ end
74
+
75
+ # @param key [String] the logical key.
76
+ def invalidate(key)
77
+ return if key.nil?
78
+ @store.delete(@keyspace.key(@family, logical_key(key)))
79
+ end
80
+
81
+ # Evict every entry in this plane, and nothing outside it.
82
+ # @return [Integer] number of keys removed, when the store can report it.
83
+ def clear
84
+ return 0 unless @store.respond_to?(:delete_matching)
85
+ @store.delete_matching(@keyspace.pattern(family: @family))
86
+ end
87
+
88
+ # Monotonic per-subject generation, used to invalidate entries that cannot
89
+ # be named.
90
+ #
91
+ # A `_User` write tells us a user id, but identity entries are keyed by
92
+ # session token and there is no reverse map, so the entries belonging to
93
+ # that user cannot be enumerated. Bumping a generation the reader checks
94
+ # invalidates all of them in O(1), including tokens this process has never
95
+ # seen, without a master-key `_Session` query.
96
+ #
97
+ # **Generation keys expire, and their TTL must exceed the longest-lived
98
+ # entry they guard.** One key per user id, written on every `_User`
99
+ # webhook, is unbounded growth on a public signup flow: every account
100
+ # that ever saves leaves a permanent key behind.
101
+ #
102
+ # The TTL cannot be chosen freely, because expiry resets the counter to
103
+ # 0 and 0 is also the value for a user who has never been bumped. If a
104
+ # generation expired while an entry written at generation 0 were still
105
+ # alive, that entry would compare current again and come back from the
106
+ # dead after having been invalidated. {#generation_ttl} is therefore
107
+ # {GENERATION_TTL_FACTOR} times the plane's entry TTL, so every entry
108
+ # predating a bump has expired on its own before the counter can reset.
109
+ # {#set} clamps per-call TTLs to keep that invariant true.
110
+ #
111
+ # A plane with no default TTL keeps permanent generations: entries there
112
+ # never expire, so no counter lifetime is safe.
113
+ #
114
+ # @param subject [String] the user id.
115
+ # @return [Integer] the new generation.
116
+ def bump_generation(subject)
117
+ return 0 if subject.nil?
118
+ key = generation_key(subject)
119
+ ttl = generation_ttl
120
+ # Prefer an atomic INCR. A read-then-write loses a concurrent bump, and
121
+ # a lost bump means an entry that should have been invalidated stays
122
+ # readable, which is the permissive direction.
123
+ if @store.respond_to?(:increment)
124
+ value = @store.increment(key).to_i
125
+ # INCR does not set an expiry, and re-applying it on each bump is
126
+ # what keeps an actively-bumped user's counter alive.
127
+ refresh_generation_expiry(key, value, ttl)
128
+ value
129
+ else
130
+ current = @store[key].to_i
131
+ @store.store(key, current + 1, ttl ? { expires: ttl } : {})
132
+ current + 1
133
+ end
134
+ end
135
+
136
+ # @param subject [String] the user id.
137
+ # @return [Integer] current generation, 0 when never bumped.
138
+ def generation(subject)
139
+ return 0 if subject.nil?
140
+ @store[generation_key(subject)].to_i
141
+ end
142
+
143
+ # Whether a value carrying `gen` is still current for `subject`.
144
+ # @return [Boolean]
145
+ def generation_current?(subject, gen)
146
+ generation(subject).to_i == gen.to_i
147
+ end
148
+
149
+ # Record that this plane was invalidated at `at`. Unlike a generation,
150
+ # which answers "is this exact subject stale", the epoch answers "is an
151
+ # entry written before now stale", which is what is needed to reject a
152
+ # *foreign* cache entry whose write time can only be derived from its
153
+ # remaining TTL.
154
+ #
155
+ # @param at [Float] wall-clock seconds; defaults to now.
156
+ # @return [Float] the recorded epoch.
157
+ def touch_epoch(at = Time.now.to_f)
158
+ current = epoch
159
+ # Never move the epoch backwards: clock skew between workers would
160
+ # otherwise re-admit entries a previous invalidation had rejected.
161
+ value = at > current ? at : current
162
+ @store.store(epoch_key, value, {})
163
+ value
164
+ end
165
+
166
+ # @return [Float] the last invalidation epoch, 0.0 when never set.
167
+ def epoch
168
+ @store[epoch_key].to_f
169
+ end
170
+
171
+ # Whether an entry written at `written_at` predates the last invalidation
172
+ # and must therefore be treated as a miss.
173
+ #
174
+ # @param written_at [Float, nil] derived write time in wall-clock seconds.
175
+ # @return [Boolean] true when the entry is safe to use.
176
+ def fresh_since_epoch?(written_at)
177
+ return false if written_at.nil?
178
+ written_at.to_f >= epoch
179
+ end
180
+
181
+ private
182
+
183
+ # Keep an entry from outliving the generation counter that invalidates
184
+ # it. A caller passing `ttl:` greater than the plane default would
185
+ # otherwise create exactly the resurrection window {#bump_generation}
186
+ # describes: the counter expires back to 0, and an entry still alive
187
+ # from before the bump compares current again.
188
+ #
189
+ # Shortening silently is the safe direction. The cost is an extra
190
+ # resolution; the cost of the alternative is a session that stays
191
+ # resolvable after it was invalidated.
192
+ def clamp_ttl(requested)
193
+ return requested if requested.nil?
194
+ ceiling = @ttl
195
+ return requested if ceiling.nil?
196
+ requested > ceiling ? ceiling : requested
197
+ end
198
+
199
+ # Apply the expiry INCR does not set, WITHOUT rewriting the value.
200
+ #
201
+ # An earlier version fell back to `store(key, value, expires: ttl)`
202
+ # when the backend had no `expire`. That is a lost update: two
203
+ # concurrent bumps interleave as INCR(2), INCR(3), store(3), store(2),
204
+ # and the counter moves BACKWARDS. Generation checks are equality
205
+ # comparisons, so a counter that goes back to a previously issued value
206
+ # re-admits every identity entry stamped with it. Those are exactly the
207
+ # entries a `_User` write was invalidating, which makes the failure a
208
+ # revoked session becoming resolvable again.
209
+ #
210
+ # Rewriting the value is therefore never acceptable here, no matter the
211
+ # width of the window. Where the backend cannot set a TTL without
212
+ # touching the value, the key simply stays non-expiring, which is the
213
+ # behavior this method was added to improve on: unbounded, but correct.
214
+ # {Parse::Cache::Redis} implements `expire` (PEXPIRE), so the Redis
215
+ # deployments where growth actually matters do get the TTL.
216
+ def refresh_generation_expiry(key, value, ttl)
217
+ return if ttl.nil?
218
+ return unless @store.respond_to?(:expire)
219
+ @store.expire(key, ttl)
220
+ rescue StandardError
221
+ # A counter without an expiry is the pre-existing behavior: correct,
222
+ # just unbounded. Never fail a webhook over it.
223
+ nil
224
+ end
225
+
226
+ # Session tokens must not appear in keys; other logical keys (a user id)
227
+ # are opaque already and stay readable for debugging.
228
+ def logical_key(key)
229
+ return key.to_s unless @digest_keys
230
+ Digest::SHA256.hexdigest(key.to_s)[0, 32]
231
+ end
232
+
233
+ # The backing store serializes values as JSON, and `JSON.generate(Set)`
234
+ # produces the string "#<Set: {...}>" rather than an array, so a Set
235
+ # written straight through comes back as a String and every read misses.
236
+ # `Parse::AtlasSearch` stores and expects a Set, so tag it and rebuild on
237
+ # the way out.
238
+ SET_TAG = "__set__"
239
+
240
+ def encode(value)
241
+ return { SET_TAG => value.to_a } if value.is_a?(Set)
242
+ value
243
+ end
244
+
245
+ def decode(value)
246
+ return Set.new(value[SET_TAG]) if value.is_a?(Hash) && value.key?(SET_TAG)
247
+ value
248
+ end
249
+
250
+ # Epoch shares the family so a plane clear resets it, and sits under a
251
+ # reserved segment so it cannot collide with a real entry.
252
+ def epoch_key
253
+ @keyspace.key(@family, "meta", "epoch")
254
+ end
255
+
256
+ # Generations live in the same family so a plane clear takes them with it,
257
+ # and are namespaced under `gen:` so they cannot collide with an entry
258
+ # whose logical key happens to be a user id.
259
+ def generation_key(subject)
260
+ @keyspace.key(@family, "gen", subject.to_s)
261
+ end
262
+ end
263
+ end
264
+ end
@@ -0,0 +1,230 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "digest"
5
+ require "json"
6
+ require "set"
7
+
8
+ module Parse
9
+ module Cache
10
+ # Read-only consumer of Parse Server's own role cache.
11
+ #
12
+ # Parse Server caches `<appId>:role:<userId>` as a JSON array of
13
+ # `"role:NAME"` strings representing the transitive closure. When the SDK
14
+ # already holds a user id from a trusted source (a webhook payload, most
15
+ # usefully), reading that entry skips the role-graph walk entirely, and the
16
+ # value was computed by Parse Server's own code so there is no divergence
17
+ # between our closure and the one the server enforces.
18
+ #
19
+ # **We never write this keyspace.** Our closure is depth-capped while Parse
20
+ # Server's is not, so injecting ours would hand the server a strict subset
21
+ # in a deep hierarchy, which it would then read back as authoritative and
22
+ # under-permission users in windows that are close to undiagnosable.
23
+ #
24
+ # **Trust.** This makes the Parse Server cache database part of the SDK's
25
+ # authorization trust base: the array feeds `permission_strings`, which is
26
+ # the only input to both the `_rperm` match and the CLP gate on the
27
+ # mongo-direct path. Anyone who can write that database can grant themselves
28
+ # roles. The credential should be restricted accordingly:
29
+ #
30
+ # ACL SETUSER parse-stack-role-reader on >SECRET \
31
+ # ~<appId>:role:* resetchannels -@all +get +pttl
32
+ #
33
+ # `+pttl` is required alongside `+get`: the freshness guard below cannot run
34
+ # without it, and PTTL is a separate ACL command from GET.
35
+ class UpstreamRoles
36
+ # Parse Server's `RedisCacheAdapter` default TTL, used to derive an
37
+ # entry's write time from its remaining TTL. Configurable because an
38
+ # operator supplies the adapter instance and may have passed another.
39
+ DEFAULT_UPSTREAM_TTL_MS = 30_000
40
+
41
+ # Reject an entry whose remaining TTL exceeds this. Neither Parse Server
42
+ # entry carries its own write time, so a very long TTL means either a
43
+ # misconfigured adapter or a value we cannot age, and an un-ageable
44
+ # authorization input is not one to trust.
45
+ DEFAULT_MAX_TTL_MS = 60_000
46
+
47
+ # Caps on the decoded array. Role counts scale with tenants, so these are
48
+ # generous: a low cap would be an outage, not a safeguard. An unbounded
49
+ # array becomes an unbounded `$in` sent to MongoDB.
50
+ MAX_ROLE_COUNT = 4096
51
+ MAX_ROLE_NAME_LENGTH = 256
52
+
53
+ # Role names may legitimately contain `/` and `:` (scoped conventions such
54
+ # as `owner/t:<id>/p:<id>` are common), so this only excludes control
55
+ # characters and the NUL byte. A tighter pattern would reject every role
56
+ # in such an app and fail closed into total loss of role access.
57
+ INVALID_ROLE_NAME = /[\x00-\x1f\x7f]/.freeze
58
+
59
+ attr_reader :app_id
60
+
61
+ # @param client [Object] a redis-rb-shaped client answering `get` and
62
+ # `pttl`. Injected rather than constructed so tests and alternative
63
+ # transports work, and so the caller owns connection lifecycle.
64
+ # @param app_id [String] Parse application id, used to build the key.
65
+ # @param roles_plane [Parse::Cache::SubCache, nil] our own role plane,
66
+ # consulted for the invalidation epoch.
67
+ # @param upstream_ttl_ms [Integer] Parse Server's configured entry TTL.
68
+ # @param max_ttl_ms [Integer] reject entries with more remaining than this.
69
+ def initialize(client:, app_id:, roles_plane: nil,
70
+ upstream_ttl_ms: DEFAULT_UPSTREAM_TTL_MS,
71
+ max_ttl_ms: DEFAULT_MAX_TTL_MS)
72
+ @client = client
73
+ @app_id = app_id.to_s
74
+ @roles_plane = roles_plane
75
+ @upstream_ttl_ms = upstream_ttl_ms
76
+ @max_ttl_ms = max_ttl_ms
77
+ end
78
+
79
+ # Key Parse Server writes for a user's role closure.
80
+ # @return [String]
81
+ def key_for(user_id)
82
+ "#{@app_id}:role:#{user_id}"
83
+ end
84
+
85
+ # Read and validate the upstream closure.
86
+ #
87
+ # @param user_id [String]
88
+ # @return [Set<String>, nil] bare role names (no `role:` prefix), or nil
89
+ # on any miss, malformed value, stale entry, or transport error. Every
90
+ # failure is a miss, so the caller falls back to computing the closure
91
+ # itself. This never fails open.
92
+ def roles_for(user_id)
93
+ return nil if user_id.nil? || user_id.to_s.empty?
94
+ key = key_for(user_id)
95
+
96
+ raw = @client.get(key)
97
+ return nil if raw.nil?
98
+
99
+ ttl = safe_pttl(key)
100
+ return nil unless usable_ttl?(ttl)
101
+ return nil unless fresh?(ttl)
102
+
103
+ decode(raw)
104
+ rescue StandardError
105
+ # Transport failures, malformed JSON, anything: treat as a miss. Note
106
+ # the deliberate absence of the exception message, which would carry the
107
+ # key and therefore the user id.
108
+ nil
109
+ end
110
+
111
+ # Whether the entry is newer than our last role invalidation.
112
+ #
113
+ # Parse Server does not clear its role cache on a `_Role` delete, so
114
+ # without this gate a delete we handled would be undone by reading their
115
+ # surviving entry. Neither entry records its write time, so it is derived
116
+ # from what remains of the configured TTL.
117
+ def fresh?(pttl_ms)
118
+ return true if @roles_plane.nil?
119
+ # A remaining TTL larger than the configured starting TTL cannot be
120
+ # aged with this reader's assumptions. Subtracting it would produce a
121
+ # negative elapsed time and a future write timestamp, which could pass
122
+ # the epoch gate and re-admit a pre-invalidation entry. Fail closed.
123
+ return false if pttl_ms > @upstream_ttl_ms
124
+ elapsed_ms = @upstream_ttl_ms - pttl_ms
125
+ written_at = Time.now.to_f - (elapsed_ms / 1000.0)
126
+ @roles_plane.fresh_since_epoch?(written_at)
127
+ end
128
+
129
+ # Whether our own database also holds Parse Server's cache entries, which
130
+ # means the two are the same database.
131
+ #
132
+ # The probe deliberately runs against OUR connection, looking for THEIR
133
+ # key pattern, rather than the reverse. An earlier version wrote a
134
+ # sentinel on our side and tried to read it back through the upstream
135
+ # client, which cannot work under the credential this class documents:
136
+ # that ACL permits only `<appId>:role:*`, so reading a
137
+ # `parse-stack:probe:*` key returns NOPERM. The rescue then swallowed it
138
+ # and reported the databases as isolated, so the check passed exactly when
139
+ # it was least able to see anything.
140
+ #
141
+ # Inverting it also removes the write. We never need to create a key to
142
+ # answer the question, and our own connection has the permissions to scan
143
+ # its own database.
144
+ #
145
+ # **This can only ever prove sharing, never isolation.** A `false`
146
+ # return means "no Parse-Server-shaped key was seen", which is what a
147
+ # genuinely separate database looks like AND what a shared database
148
+ # looks like before Parse Server has cached its first role closure. A
149
+ # freshly deployed stack is in that second state, and it is exactly
150
+ # when an operator is most likely to run the check. Callers must not
151
+ # report `false` as "isolated"; see
152
+ # {Parse::Cache::Redis#verify_upstream_isolation!}, which pairs this
153
+ # with a sentinel round-trip that CAN establish the negative.
154
+ #
155
+ # @param scanner [Object] our own store, answering `scan_exist?` or a
156
+ # redis-rb-shaped `scan`.
157
+ # @return [Boolean] true when Parse Server's keys are visible in our
158
+ # database. False means only "not detected".
159
+ def shares_database_with?(scanner)
160
+ pattern = "#{@app_id}:role:*"
161
+ client = scanner.respond_to?(:scan) ? scanner : nil
162
+ return false if client.nil?
163
+
164
+ cursor = "0"
165
+ # Bounded: a handful of iterations is enough to answer "are any of their
166
+ # keys here", and an unbounded scan on a large database would be a
167
+ # startup stall.
168
+ 8.times do
169
+ cursor, keys = client.scan(cursor, match: pattern, count: 100)
170
+ # Callers that use a broad pattern are responsible for filtering out
171
+ # anything that is not a genuine Parse Server entry before it reaches
172
+ # here. `Parse::Cache::Redis` wraps its client in a scanner that
173
+ # keeps only keys matching the upstream `<appId>:role:<userId>`
174
+ # shape, so the filtering lives next to the wiring rather than being
175
+ # duplicated in both places.
176
+ return true unless keys.empty?
177
+ break if cursor == "0"
178
+ end
179
+ false
180
+ rescue StandardError
181
+ # Cannot tell. Report not-shared so a probe failure never blocks
182
+ # startup; the warning path is advisory, not a gate.
183
+ false
184
+ end
185
+
186
+ private
187
+
188
+ def safe_pttl(key)
189
+ return nil unless @client.respond_to?(:pttl)
190
+ @client.pttl(key)
191
+ rescue StandardError
192
+ nil
193
+ end
194
+
195
+ # -1 means no expiry, -2 means the key vanished between GET and PTTL, nil
196
+ # means we could not ask. An entry we cannot age is one we cannot trust.
197
+ def usable_ttl?(ttl)
198
+ return false if ttl.nil?
199
+ ttl = ttl.to_i
200
+ return false if ttl.negative?
201
+ return false if ttl > @max_ttl_ms
202
+ true
203
+ end
204
+
205
+ # Validate strictly and strip exactly one `role:` prefix. Our own
206
+ # `role_names` set holds bare names and re-adds the prefix when building
207
+ # permission strings, so reading prefixed values straight through would
208
+ # yield `role:role:X` and silently under-permission every query.
209
+ def decode(raw)
210
+ parsed = raw.is_a?(String) ? JSON.parse(raw) : raw
211
+ return nil unless parsed.is_a?(Array)
212
+ return Set.new if parsed.empty?
213
+ return nil if parsed.size > MAX_ROLE_COUNT
214
+
215
+ names = Set.new
216
+ parsed.each do |entry|
217
+ return nil unless entry.is_a?(String)
218
+ return nil if entry.length > MAX_ROLE_NAME_LENGTH
219
+ return nil unless entry.start_with?("role:")
220
+ name = entry.sub(/\Arole:/, "")
221
+ return nil if name.empty?
222
+ return nil if name == "*"
223
+ return nil if name.match?(INVALID_ROLE_NAME)
224
+ names << name
225
+ end
226
+ names
227
+ end
228
+ end
229
+ end
230
+ end
@@ -67,7 +67,7 @@ module Parse
67
67
  # 3. A session-token-authenticated request (the existing check
68
68
  # below; session token wins over master key).
69
69
  header_disable = env[:request_headers][DISABLE_MASTER_KEY].present?
70
- fiber_disable = Parse.master_key_disabled?
70
+ fiber_disable = Parse.master_key_disabled?
71
71
  unless @master_key.blank? || header_disable || fiber_disable
72
72
  headers[MASTER_KEY] = @master_key
73
73
  end
@@ -388,8 +388,7 @@ module Parse
388
388
  params = Faraday::Utils.parse_query(query_string.to_s) || {}
389
389
  body = { "_method" => "GET" }
390
390
  params.each do |key, value|
391
- body[key] =
392
- begin
391
+ body[key] = begin
393
392
  JSON.parse(value)
394
393
  rescue JSON::ParserError, TypeError
395
394
  value
@@ -91,6 +91,23 @@ module Parse
91
91
  ns = ns.chomp(":")
92
92
  @namespace = ns.empty? ? nil : ns
93
93
 
94
+ # The keyspace owns physical key layout and the patterns that clear it,
95
+ # so key generation and eviction cannot drift apart. Previously this
96
+ # middleware composed keys from its own `@namespace` while
97
+ # `Parse::Cache::Redis` held a separate one, and `clear_cache!` used
98
+ # only the latter: a client namespaced here but not there would clear
99
+ # every SDK key on the database instead of its own.
100
+ #
101
+ # When no keyspace is supplied the middleware keeps writing legacy
102
+ # un-prefixed keys, so an upgrade that does not opt in behaves exactly
103
+ # as before.
104
+ @keyspace = @opts[:keyspace]
105
+ # During the transition, also delete the pre-keyspace form of a key on
106
+ # invalidation. Without this, a rolling deploy has new workers writing
107
+ # keyspaced keys while old workers still read legacy ones, so a write
108
+ # served by a new worker never invalidates what an old worker serves.
109
+ @delete_legacy_variants = @opts.fetch(:delete_legacy_variants, true)
110
+
94
111
  unless [:key?, :[], :delete, :store].all? { |method| @store.respond_to?(method) }
95
112
  raise ArgumentError, "Caching store object must a Moneta key/value store."
96
113
  end
@@ -135,11 +152,20 @@ module Parse
135
152
  method = env.method
136
153
  @cache_key = url.to_s
137
154
 
155
+ # Auth discriminator. A master-key request bypasses ACL, CLP and
156
+ # protectedFields, so the same URL returns a strictly fuller body than a
157
+ # session request, and two sessions can differ from each other through
158
+ # protectedFields entity rules and row ACLs. These must never share a
159
+ # cache entry or the cache would hand privileged fields to an
160
+ # unprivileged caller.
161
+ @cache_auth = :anon
138
162
  if @request_headers.key?(SESSION_TOKEN)
139
163
  @session_token = @request_headers[SESSION_TOKEN]
140
164
  hashed_token = Digest::SHA256.hexdigest(@session_token.to_s)[0, 32]
165
+ @cache_auth = hashed_token
141
166
  @cache_key = "#{hashed_token}:#{@cache_key}" # prefix with hashed token
142
167
  elsif @request_headers.key?(MASTER_KEY)
168
+ @cache_auth = :master
143
169
  @cache_key = "mk:#{@cache_key}" # prefix for master key requests
144
170
  end
145
171
 
@@ -160,6 +186,13 @@ module Parse
160
186
  # tenant/app cleanly without touching another app's entries.
161
187
  @cache_key = "#{@namespace}:#{@cache_key}" if @namespace
162
188
 
189
+ # Keep the legacy key for dual-delete on invalidation, then switch the
190
+ # live key to the keyspace form when one is configured.
191
+ @legacy_cache_key = @cache_key
192
+ if @keyspace
193
+ @cache_key = @keyspace.cache_key(url, auth: @cache_auth, tenant: @cache_tenant)
194
+ end
195
+
163
196
  url_path = url.path
164
197
 
165
198
  begin
@@ -184,7 +217,7 @@ module Parse
184
217
  # check if the store was from a legacy parse-stack cache value which
185
218
  # is stored as Faraday::Env. T\he new system stores less content in a simple hash
186
219
  # for improved interoperability and access time.
187
- body = nil
220
+ body = nil
188
221
  response_headers = nil
189
222
  if cache_data.is_a?(Faraday::Env)
190
223
  body = cache_data.respond_to?(:body) ? cache_data.body : nil
@@ -229,10 +262,17 @@ module Parse
229
262
  #non GET requets should clear the cache for that same resource path.
230
263
  #ex. a POST to /1/classes/Artist/<objectId> should delete the cache for a GET
231
264
  # request for the same '/1/classes/Artist/<objectId>' where objectId are equivalent
232
- delete_cache_variants(url)
265
+ delete_cache_variants(url, resource: true)
233
266
  instrument_cache(:delete, method: method, url_path: url_path)
234
267
  end
235
- rescue ::TypeError, Errno::EINVAL, Redis::CannotConnectError, Redis::TimeoutError, ConnectionPool::TimeoutError => e
268
+ # `Redis::CommandError` covers the failures a scoped eviction can now
269
+ # produce that a plain GET/SET never did: a NOPERM from a restricted
270
+ # ACL, an UNLINK the server does not implement, and a CROSSSLOT refusal
271
+ # under Redis Cluster. Without it those escape the middleware and turn a
272
+ # cache problem into a failed application request, which inverts the
273
+ # whole point of the cache being optional.
274
+ rescue ::TypeError, Errno::EINVAL, Redis::CannotConnectError, Redis::TimeoutError,
275
+ Redis::CommandError, ConnectionPool::TimeoutError => e
236
276
  # if the cache store fails to connect, catch the exception but proceed
237
277
  # with the regular request, but turn off caching for this request. It is possible
238
278
  # that the cache connection resumes at a later point, so this is temporary.
@@ -312,7 +352,46 @@ module Parse
312
352
  # cleanup of stale pre-namespace entries) and non-GET writes (cache
313
353
  # invalidation for the resource).
314
354
  # @!visibility private
315
- def delete_cache_variants(url)
355
+ # @param resource [Boolean] when true, evict every auth variant of this
356
+ # resource, not just the caller's own entry. Only a write should do
357
+ # that. On a GET miss this method is called defensively to clear stale
358
+ # siblings, and evicting resource-wide there would destroy other
359
+ # sessions' perfectly valid entries on every single cache miss.
360
+ def delete_cache_variants(url, resource: false)
361
+ delete_keyspace_variants(url) if resource && @keyspace
362
+ delete_legacy_variants(url) if legacy_variants?
363
+ @store.delete @cache_key # final key
364
+ end
365
+
366
+ # Whether to also evict the pre-keyspace key shape. Always true before a
367
+ # keyspace is configured, since that shape is the only one in use.
368
+ # @!visibility private
369
+ def legacy_variants?
370
+ @keyspace.nil? || @delete_legacy_variants
371
+ end
372
+
373
+ # Evict every auth variant of this resource, not just the caller's own.
374
+ #
375
+ # The old two-variant delete could name the anonymous and master-key
376
+ # siblings but had no way to enumerate *other sessions'* entries, so a
377
+ # write by one user left every other user holding a stale copy until TTL.
378
+ # A scan-capable store can express that as one pattern; anything else
379
+ # falls back to the variants we can name.
380
+ # @!visibility private
381
+ def delete_keyspace_variants(url)
382
+ pattern = @keyspace.resource_pattern(url, tenant: @cache_tenant)
383
+ if @store.respond_to?(:delete_matching)
384
+ @store.delete_matching(pattern)
385
+ else
386
+ @store.delete @keyspace.cache_key(url, auth: :anon, tenant: @cache_tenant)
387
+ @store.delete @keyspace.cache_key(url, auth: :master, tenant: @cache_tenant)
388
+ end
389
+ end
390
+
391
+ # Evict the pre-keyspace key shape so a rolling deploy does not leave old
392
+ # workers serving entries that a new worker's write should have killed.
393
+ # @!visibility private
394
+ def delete_legacy_variants(url)
316
395
  if @namespace
317
396
  # Namespaced: only delete our app's variants so a write through
318
397
  # client A doesn't blow away client B's cache when both share Redis.
@@ -322,7 +401,7 @@ module Parse
322
401
  @store.delete url.to_s # regular
323
402
  @store.delete "mk:#{url.to_s}" # master key cache-key
324
403
  end
325
- @store.delete @cache_key # final key
404
+ @store.delete @legacy_cache_key if @legacy_cache_key
326
405
  end
327
406
  end #Caching
328
407
  end #Middleware