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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +409 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +7 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +167 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +5 -4
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +1 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +11 -11
- data/lib/parse/embeddings/video_source.rb +7 -6
- data/lib/parse/embeddings/voyage.rb +59 -61
- data/lib/parse/embeddings.rb +28 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +97 -15
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +21 -23
- data/lib/parse/model/core/fetching.rb +2 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +2 -2
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +4 -5
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +132 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +66 -41
- data/lib/parse/vector_search.rb +14 -11
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +22 -4
- data/parse-stack-next.gemspec +6 -6
- 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
|
|
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
|
data/lib/parse/client/caching.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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 @
|
|
404
|
+
@store.delete @legacy_cache_key if @legacy_cache_key
|
|
326
405
|
end
|
|
327
406
|
end #Caching
|
|
328
407
|
end #Middleware
|