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,466 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "set"
5
+ require "digest"
6
+
7
+ module Parse
8
+ # Resolution of a caller's identity and inherited roles, and the caches that
9
+ # make that resolution cheap.
10
+ #
11
+ # **Why this is not part of Atlas Search.** It used to be. Session-token
12
+ # resolution and role-closure expansion were written for
13
+ # `Parse::AtlasSearch`, because `$search` was the first thing that ran
14
+ # aggregations straight against MongoDB and therefore the first thing that
15
+ # had to enforce ACLs itself. Everything since has reached back through it:
16
+ # `Parse::ACLScope` called `Parse::AtlasSearch::Session.resolve`, and
17
+ # `Parse::MongoDB.aggregate` calls `Parse::ACLScope`, so
18
+ # `Parse::Query#results_direct` on a plain query with no `$search` anywhere
19
+ # in it depended on the Atlas Search namespace to decide who the caller was.
20
+ # That is backwards. Deciding who someone is and what they may read is
21
+ # authorization infrastructure, and Atlas Search is one consumer of it:
22
+ #
23
+ # Atlas Search ─┐
24
+ # Aggregates ──┼─> Parse::Authorization ─> identity / role caches
25
+ # Direct query ─┘
26
+ #
27
+ # Policy that is genuinely about Atlas Search stays on Atlas Search:
28
+ # `Parse::AtlasSearch.require_session_token` decides whether `$search` may
29
+ # run anonymously, which is a question about that feature, not about
30
+ # identity.
31
+ #
32
+ # **Two caches, because they invalidate on different events.**
33
+ #
34
+ # * The **identity plane** maps a session token to a user id. Long TTL
35
+ # (1 hour), invalidated by logout and by a `_User` write. Named
36
+ # `identity_cache` rather than `session_cache` because it stores neither
37
+ # `_Session` rows nor session objects: it stores one string per token.
38
+ # The old name led readers to reason about `_Session` semantics that
39
+ # were never involved.
40
+ #
41
+ # * The **role plane** maps a user id to a `Set` of role names. Short TTL
42
+ # (30 seconds), invalidated by any `_Role` write. Stale entries here
43
+ # produce wrong ACL decisions rather than merely slow ones, so the
44
+ # default is conservative.
45
+ #
46
+ # **State belongs to a client, not to the process.** A {Context} is owned by
47
+ # one {Parse::Client} and reachable as `client.authorization`. Two named
48
+ # clients pointed at two Parse applications must never resolve a token
49
+ # against each other's caches or each other's `/users/me`, which is exactly
50
+ # what a set of module-level globals allowed. {Parse::Authorization.configure}
51
+ # exists as a boundary convenience and configures the DEFAULT client's
52
+ # context; below the boundary, `client:` is required and has no default.
53
+ module Authorization
54
+ # Raised when a `session_token` cannot be resolved: an invalid token, an
55
+ # expired session, or a `/users/me` that returned an error. Callers should
56
+ # treat it as a 401-equivalent.
57
+ class InvalidSession < StandardError; end
58
+
59
+ # Default cache: a process-local hash with per-entry TTL, guarded by a
60
+ # `Mutex`. Fine for a single process. Multi-process deployments (Puma
61
+ # workers, Sidekiq processes) get one of these per process and should
62
+ # install a shared plane instead, which is what
63
+ # `Parse::Cache::Redis#scoped(...).identity` / `.roles` return.
64
+ class MemoryCache
65
+ def initialize
66
+ @data = {}
67
+ @mutex = Mutex.new
68
+ end
69
+
70
+ # @param key [String]
71
+ # @return [Object, nil] the cached value, or `nil` when the key is
72
+ # missing or its TTL has elapsed. Expired entries are evicted lazily
73
+ # on read.
74
+ def get(key)
75
+ @mutex.synchronize do
76
+ entry = @data[key]
77
+ return nil if entry.nil?
78
+ if entry[:expires_at] < Time.now
79
+ @data.delete(key)
80
+ return nil
81
+ end
82
+ entry[:value]
83
+ end
84
+ end
85
+
86
+ # @param key [String]
87
+ # @param value [Object]
88
+ # @param ttl [Numeric] seconds until the entry expires.
89
+ def set(key, value, ttl:)
90
+ @mutex.synchronize do
91
+ @data[key] = { value: value, expires_at: Time.now + ttl }
92
+ end
93
+ end
94
+
95
+ # @param key [String] cache key to forget.
96
+ def invalidate(key)
97
+ @mutex.synchronize { @data.delete(key) }
98
+ end
99
+
100
+ # Drop every entry.
101
+ def clear
102
+ @mutex.synchronize { @data.clear }
103
+ end
104
+ end
105
+
106
+ # The outcome of resolving a caller. `user_id` is the `_User.objectId`
107
+ # owning the session, or `nil` for an anonymous caller. `role_names` is a
108
+ # `Set` of bare role names (no `role:` prefix) the user inherits
109
+ # permissions from.
110
+ Resolved = Struct.new(:user_id, :role_names) do
111
+ # The canonical `_rperm` / `_wperm` permission-string set for this
112
+ # caller. Always includes `"*"`. Includes `user_id` when present, and
113
+ # `"role:#{name}"` for each inherited role.
114
+ # @return [Array<String>]
115
+ def permission_strings
116
+ out = ["*"]
117
+ out << user_id if user_id && !user_id.empty?
118
+ role_names.each { |name| out << "role:#{name}" if name && !name.empty? }
119
+ out.uniq
120
+ end
121
+
122
+ # @return [Boolean] `true` for the anonymous case.
123
+ def anonymous?
124
+ user_id.nil? || user_id.empty?
125
+ end
126
+ end
127
+
128
+ # Per-client authorization state: the two caches, their TTLs, and the
129
+ # optional upstream-role reader.
130
+ #
131
+ # One of these is owned by each {Parse::Client}. It deliberately does NOT
132
+ # own the HTTP client: it holds a back-reference and asks the client to
133
+ # make the `/users/me` call, so there is exactly one place that knows how
134
+ # to talk to a Parse application and it is the client itself.
135
+ class Context
136
+ # @return [Object] the identity plane. Maps session token to user id.
137
+ attr_accessor :identity_cache
138
+
139
+ # @return [Object] the role plane. Maps user id to a Set of role names.
140
+ attr_accessor :role_cache
141
+
142
+ # @return [Integer] identity-entry TTL in seconds.
143
+ attr_accessor :identity_cache_ttl
144
+
145
+ # @return [Integer] role-entry TTL in seconds.
146
+ attr_accessor :role_cache_ttl
147
+
148
+ # @return [#roles_for, nil] read-only reader for Parse Server's own role
149
+ # cache. Never consumed for authorization; see {#compare_upstream_roles}.
150
+ attr_accessor :upstream_role_reader
151
+
152
+ # @return [Boolean] when true, and a reader is set, every role
153
+ # resolution also reads the upstream closure and emits a
154
+ # `parse.cache.role_compare` event. The comparison NEVER changes what
155
+ # {#resolve} returns. The upstream value would become an authorization
156
+ # input the moment it were consumed, and it comes from a database this
157
+ # SDK does not own, so it stays observable-only until the two closures
158
+ # have been reconciled against real traffic.
159
+ attr_accessor :compare_upstream_roles
160
+
161
+ # @return [Parse::Client] the client this context authorizes for.
162
+ attr_reader :client
163
+
164
+ DEFAULT_IDENTITY_TTL = 3600
165
+ DEFAULT_ROLE_TTL = 30
166
+
167
+ # Depth cap for the role-graph walk. Bounds a cyclic or pathological
168
+ # hierarchy; see {Parse::Role.all_for_user}.
169
+ ROLE_GRAPH_MAX_DEPTH = 10
170
+
171
+ def initialize(client:)
172
+ @client = client
173
+ @identity_cache = MemoryCache.new
174
+ @role_cache = MemoryCache.new
175
+ @identity_cache_ttl = DEFAULT_IDENTITY_TTL
176
+ @role_cache_ttl = DEFAULT_ROLE_TTL
177
+ @upstream_role_reader = nil
178
+ @compare_upstream_roles = false
179
+ end
180
+
181
+ # Apply settings, leaving anything not passed unchanged.
182
+ # @return [self]
183
+ def configure(identity_cache: nil, role_cache: nil,
184
+ identity_cache_ttl: nil, role_cache_ttl: nil,
185
+ upstream_role_reader: nil, compare_upstream_roles: nil)
186
+ @identity_cache = identity_cache unless identity_cache.nil?
187
+ @role_cache = role_cache unless role_cache.nil?
188
+ @identity_cache_ttl = identity_cache_ttl unless identity_cache_ttl.nil?
189
+ @role_cache_ttl = role_cache_ttl unless role_cache_ttl.nil?
190
+ @upstream_role_reader = upstream_role_reader unless upstream_role_reader.nil?
191
+ @compare_upstream_roles = compare_upstream_roles unless compare_upstream_roles.nil?
192
+ self
193
+ end
194
+
195
+ # Resolve a session token to the requesting user and the transitive set
196
+ # of role names whose `role:NAME` permission strings should be checked
197
+ # against `_rperm`.
198
+ #
199
+ # A `nil` or empty token yields an anonymous {Resolved}. The caller
200
+ # decides whether that is acceptable; `Parse::ACLScope.require_session_token`
201
+ # and `Parse::AtlasSearch.require_session_token` are where that policy
202
+ # lives.
203
+ #
204
+ # The two lookups are cached independently, so several sessions
205
+ # belonging to one user share a single role-graph walk.
206
+ #
207
+ # @param session_token [String, nil]
208
+ # @return [Resolved]
209
+ # @raise [InvalidSession] when `/users/me` cannot resolve the token.
210
+ def resolve(session_token)
211
+ return Resolved.new(nil, Set.new) if session_token.nil? || session_token.to_s.empty?
212
+
213
+ user_id = lookup_user_id(session_token.to_s)
214
+ Resolved.new(user_id, lookup_role_names(user_id))
215
+ end
216
+
217
+ # Resolve a user id that is already trusted, skipping `/users/me`.
218
+ # Used by the `acl_user:` path, which has a User pointer rather than a
219
+ # token.
220
+ # @param user_id [String]
221
+ # @return [Resolved]
222
+ def resolve_user(user_id)
223
+ return Resolved.new(nil, Set.new) if user_id.nil? || user_id.to_s.empty?
224
+ Resolved.new(user_id.to_s, lookup_role_names(user_id.to_s))
225
+ end
226
+
227
+ # Forget one session token. Call from a logout path that revokes
228
+ # out-of-band; `Parse::Cache::Invalidation` does this automatically from
229
+ # the `_Session` `after_logout` trigger when webhooks are installed.
230
+ #
231
+ # The role plane is keyed by user id and is unaffected; use
232
+ # {#invalidate_user_roles} for that.
233
+ # @param session_token [String]
234
+ def invalidate(session_token)
235
+ return if session_token.nil?
236
+ @identity_cache.invalidate(session_token.to_s)
237
+ end
238
+
239
+ # Forget one user's cached role closure. Call after any `_Role.users`
240
+ # mutation affecting them.
241
+ # @param user_id [String]
242
+ def invalidate_user_roles(user_id)
243
+ return if user_id.nil?
244
+ @role_cache.invalidate(user_id.to_s)
245
+ end
246
+
247
+ # Drop every entry in both planes.
248
+ def reset_caches!
249
+ @identity_cache.clear if @identity_cache.respond_to?(:clear)
250
+ @role_cache.clear if @role_cache.respond_to?(:clear)
251
+ end
252
+
253
+ def inspect
254
+ "#<Parse::Authorization::Context client=#{@client.respond_to?(:application_id) ? @client.application_id : @client.class}>"
255
+ end
256
+
257
+ private
258
+
259
+ # Resolve token to user id through the identity plane, falling through
260
+ # to `/users/me` on this context's OWN client. Threading the client here
261
+ # is the substance of the refactor: a global resolver would have asked
262
+ # `Parse.client`, so a token minted by a secondary application would be
263
+ # validated against the default application and either fail or, worse,
264
+ # match a different user with the same token shape.
265
+ def lookup_user_id(session_token)
266
+ cached = cached_user_id(session_token)
267
+ return cached unless cached.nil?
268
+
269
+ response = begin
270
+ @client.current_user(session_token)
271
+ rescue => e
272
+ raise InvalidSession, "session token lookup failed: #{e.class}: #{e.message}"
273
+ end
274
+ raise InvalidSession, "session token invalid or expired" if response.nil? || response.error?
275
+
276
+ result = response.result
277
+ user_id = result.is_a?(Hash) ? (result["objectId"] || result[:objectId]) : nil
278
+ raise InvalidSession, "session token resolved no user objectId" if user_id.nil? || user_id.to_s.empty?
279
+
280
+ user_id = user_id.to_s
281
+ store_user_id(session_token, user_id)
282
+ user_id
283
+ end
284
+
285
+ # Read the identity plane and, where the plane supports it, check that
286
+ # the entry's generation is still current.
287
+ #
288
+ # A `_User` write bumps that generation (see
289
+ # {Parse::Cache::Invalidation}), so a modified or revoked user's cached
290
+ # entries are rejected on the very next read instead of staying
291
+ # resolvable for the rest of {#identity_cache_ttl}. The default
292
+ # {MemoryCache} has no generation contract, so this feature-detects and
293
+ # falls back to trusting the bare value, adding no round trip.
294
+ #
295
+ # @return [String, nil] `nil` on any miss: absent, stale generation, or
296
+ # a shape this reader does not recognize. An unrecognized shape
297
+ # includes a bare `String` written by a generation-capable plane
298
+ # before generations existed; treating it as a miss costs one
299
+ # re-resolution rather than trusting it unchecked.
300
+ def cached_user_id(session_token)
301
+ cache = @identity_cache
302
+ raw = cache.get(session_token)
303
+ return nil if raw.nil?
304
+
305
+ unless generation_capable?(cache)
306
+ return raw.is_a?(String) ? raw : nil
307
+ end
308
+
309
+ return nil unless raw.is_a?(Hash)
310
+ user_id = raw["user_id"] || raw[:user_id]
311
+ gen = raw.key?("gen") ? raw["gen"] : raw[:gen]
312
+ return nil if user_id.nil? || gen.nil?
313
+ return nil unless cache.generation_current?(user_id, gen)
314
+ user_id
315
+ end
316
+
317
+ # Write the identity entry, tagging it with the subject's current
318
+ # generation when the plane can track one.
319
+ def store_user_id(session_token, user_id)
320
+ cache = @identity_cache
321
+ if generation_capable?(cache)
322
+ cache.set(session_token, { "user_id" => user_id, "gen" => cache.generation(user_id) },
323
+ ttl: @identity_cache_ttl)
324
+ else
325
+ cache.set(session_token, user_id, ttl: @identity_cache_ttl)
326
+ end
327
+ end
328
+
329
+ def generation_capable?(cache)
330
+ cache.respond_to?(:generation) && cache.respond_to?(:generation_current?)
331
+ end
332
+
333
+ # Resolve user id to a Set of role names through the role plane,
334
+ # falling through to {Parse::Role.all_for_user}.
335
+ #
336
+ # Ordinary failures degrade to an empty set rather than raising: a
337
+ # Parse Server hiccup during the role walk must not turn every query
338
+ # into a 500, and the cost is a query that misses role-restricted rows.
339
+ #
340
+ # The three re-raised classes are deliberate exceptions to that. A
341
+ # denied-operator probe, a timeout exhaustion, and a CLP denial are
342
+ # attack signals or explicit policy denials. Swallowing them would
343
+ # downgrade the caller to public-only permissions AND hide the signal
344
+ # from the operator, which is the worst of both.
345
+ def lookup_role_names(user_id)
346
+ return Set.new if user_id.nil? || user_id.empty?
347
+
348
+ cached = @role_cache.get(user_id)
349
+ if cached.is_a?(Set)
350
+ compare_with_upstream(user_id, cached)
351
+ return cached
352
+ end
353
+
354
+ pointer = Parse::Pointer.new(Parse::Model::CLASS_USER, user_id)
355
+ names = begin
356
+ # `client:` matters as much here as it does for the token
357
+ # lookup above. Without it the identity resolves against THIS
358
+ # client while its role closure is walked against the default
359
+ # application, so a user of application B would be granted
360
+ # application A's roles by name.
361
+ Parse::Role.all_for_user(pointer, max_depth: ROLE_GRAPH_MAX_DEPTH, client: @client)
362
+ rescue Parse::MongoDB::DeniedOperator,
363
+ Parse::MongoDB::ExecutionTimeout,
364
+ Parse::CLPScope::Denied
365
+ raise
366
+ rescue
367
+ Set.new
368
+ end
369
+ @role_cache.set(user_id, names, ttl: @role_cache_ttl)
370
+ compare_with_upstream(user_id, names)
371
+ names
372
+ end
373
+
374
+ # Opt-in, compare-only read of Parse Server's own role cache. This never
375
+ # changes what {#lookup_role_names} returns: `computed` is already
376
+ # decided by the time this runs. Its only job is to emit an event so the
377
+ # two closures can be compared out-of-band before anything is switched
378
+ # to consume the upstream value.
379
+ #
380
+ # Inert unless both the switch and a reader are set, so it costs one
381
+ # boolean check when off. Every exception is swallowed: this is
382
+ # instrumentation and must never affect resolution.
383
+ def compare_with_upstream(user_id, computed)
384
+ return unless @compare_upstream_roles
385
+ reader = @upstream_role_reader
386
+ return if reader.nil?
387
+
388
+ upstream = begin
389
+ reader.roles_for(user_id)
390
+ rescue StandardError
391
+ nil
392
+ end
393
+
394
+ emit_role_compare(user_id, computed, upstream)
395
+ nil
396
+ rescue StandardError
397
+ nil
398
+ end
399
+
400
+ # Emit `parse.cache.role_compare`. Follows the redaction discipline of
401
+ # `Parse::Middleware::Caching#instrument_cache` and
402
+ # `Parse::CreateLock#instrument`: no role names and no raw user id in
403
+ # the payload, a truncated digest only.
404
+ def emit_role_compare(user_id, computed, upstream)
405
+ return unless defined?(ActiveSupport::Notifications)
406
+
407
+ upstream_nil = upstream.nil?
408
+ only_in_ours = upstream_nil ? computed.size : (computed - upstream).size
409
+ only_in_upstream = upstream_nil ? 0 : (upstream - computed).size
410
+
411
+ ActiveSupport::Notifications.instrument("parse.cache.role_compare", {
412
+ user_digest: Digest::SHA256.hexdigest(user_id.to_s)[0, 16],
413
+ upstream_nil: upstream_nil,
414
+ matched: !upstream_nil && only_in_ours.zero? && only_in_upstream.zero?,
415
+ computed_size: computed.size,
416
+ upstream_size: upstream_nil ? nil : upstream.size,
417
+ only_in_ours: only_in_ours,
418
+ only_in_upstream: only_in_upstream,
419
+ })
420
+ nil
421
+ rescue StandardError
422
+ nil
423
+ end
424
+ end
425
+
426
+ class << self
427
+ # Configure the DEFAULT client's authorization context.
428
+ #
429
+ # This is a boundary convenience, matching the shape already used for
430
+ # `Parse::AtlasSearch.search(..., client: Parse.client)`: the common
431
+ # single-application case should not have to name the client. It is
432
+ # explicitly NOT the source of truth. The state lives on
433
+ # `client.authorization`, one context per client, which is what stops
434
+ # two named clients resolving tokens against each other's caches. To
435
+ # configure a secondary application, call
436
+ # `other_client.authorization.configure(...)` directly.
437
+ #
438
+ # @return [Parse::Authorization::Context] the default client's context.
439
+ def configure(**kwargs)
440
+ Parse.client.authorization.configure(**kwargs)
441
+ end
442
+
443
+ # Resolve a session token against a specific client.
444
+ #
445
+ # `client:` is required and has no default. Below the API boundary
446
+ # there is no such thing as "the" client, and defaulting to
447
+ # `Parse.client` here is precisely the bug this module exists to close:
448
+ # a token belonging to application B would be validated against
449
+ # application A.
450
+ #
451
+ # @param session_token [String, nil]
452
+ # @param client [Parse::Client]
453
+ # @return [Resolved]
454
+ def resolve(session_token, client:)
455
+ raise ArgumentError, "Parse::Authorization.resolve requires client:" if client.nil?
456
+ client.authorization.resolve(session_token)
457
+ end
458
+
459
+ # @see Context#resolve_user
460
+ def resolve_user(user_id, client:)
461
+ raise ArgumentError, "Parse::Authorization.resolve_user requires client:" if client.nil?
462
+ client.authorization.resolve_user(user_id)
463
+ end
464
+ end
465
+ end
466
+ end
@@ -0,0 +1,167 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ module Parse
5
+ module Cache
6
+ # Registers the webhook triggers that keep the identity and role planes
7
+ # honest without relying on application discipline.
8
+ #
9
+ # Before this, the documented contract asked applications to call
10
+ # `Session.invalidate` and `invalidate_user_roles` from their own logout and
11
+ # role-mutation paths. That depends on every application remembering, and it
12
+ # misses role changes made by any other client: a mobile SDK, the dashboard,
13
+ # or Node cloud code. Registering our own triggers moves the responsibility
14
+ # to the SDK and covers writes from every source Parse Server sees.
15
+ #
16
+ # Parse::Cache::Invalidation.install!(cache)
17
+ #
18
+ # The TTL remains the backstop. These triggers require the application to
19
+ # run a webhook endpoint Parse Server can reach and to have registered the
20
+ # hooks; unregistered or unreachable, the TTL is the only bound on
21
+ # staleness. TTL *and* hooks, not TTL or hooks.
22
+ module Invalidation
23
+ # Classes we register against, exposed for tests and diagnostics.
24
+ # Literal class names rather than Parse::Model constants: this file is
25
+ # required from Parse::Client before Parse::Model is defined, and these
26
+ # are fixed Parse Server protocol names, not app-domain classes.
27
+ USER_CLASS = "_User"
28
+ SESSION_CLASS = "_Session"
29
+ ROLE_CLASS = "_Role"
30
+
31
+ TRIGGERS = {
32
+ role: [[:after_save, ROLE_CLASS], [:after_delete, ROLE_CLASS]],
33
+ identity: [[:after_save, USER_CLASS],
34
+ [:after_delete, USER_CLASS],
35
+ [:after_logout, SESSION_CLASS]],
36
+ }.freeze
37
+
38
+ class << self
39
+ # Install the triggers for a cache exposing `roles` and `identity`
40
+ # planes.
41
+ #
42
+ # @param cache [Parse::Cache::Redis] a keyspace-configured cache.
43
+ # @return [Array<Array>] the routes registered.
44
+ def install!(cache)
45
+ unless cache.respond_to?(:roles) && cache.respond_to?(:identity)
46
+ raise ArgumentError,
47
+ "Parse::Cache::Invalidation requires a cache with role and identity planes"
48
+ end
49
+ registered = []
50
+ registered.concat(install_role_triggers!(cache))
51
+ registered.concat(install_identity_triggers!(cache))
52
+ registered
53
+ end
54
+
55
+ private
56
+
57
+ def install_role_triggers!(cache)
58
+ TRIGGERS[:role].map do |(type, class_name)|
59
+ Parse::Webhooks.route(type, class_name) do |payload|
60
+ guard do
61
+ # A role write does not say which users are affected: membership
62
+ # and hierarchy changes arrive as relation deltas on `users` and
63
+ # `roles`, and the cached value is a flattened transitive closure,
64
+ # so a parent-role change reaches the members of every child.
65
+ # Clearing the whole plane is both correct and cheap under a
66
+ # scoped SCAN. Parse Server does the same, for the same reason.
67
+ cache.roles.clear
68
+ # Stamp the epoch so a *foreign* role entry written before this
69
+ # moment is rejected on read. Parse Server does not clear its own
70
+ # role cache on a `_Role` delete, so without this the next read
71
+ # would take its stale entry back and our clear would shorten
72
+ # revocation by nothing.
73
+ cache.roles.touch_epoch
74
+ end
75
+ true
76
+ end
77
+ [type, class_name]
78
+ end
79
+ end
80
+
81
+ def install_identity_triggers!(cache)
82
+ TRIGGERS[:identity].map do |(type, class_name)|
83
+ Parse::Webhooks.route(type, class_name) do |payload|
84
+ guard do
85
+ case type
86
+ when :after_logout
87
+ # The only trigger Parse Server permits on `_Session`. The
88
+ # object's own sessionToken is scrubbed from the payload, but
89
+ # the token is captured from the requesting user before
90
+ # scrubbing, and for a logout that user *is* the session being
91
+ # ended. A master-key logout carries no user, so fall back to
92
+ # the generation bump.
93
+ #
94
+ # Pass the RAW token, not a pre-hashed digest.
95
+ # `Parse::Cache::SubCache#invalidate` hashes its `key`
96
+ # argument internally for the `:idn` family (see
97
+ # `SubCache#logical_key`), the same way `#get` / `#set` do —
98
+ # that is what makes a `set(raw_token, ...)` /
99
+ # `get(raw_token)` pair round-trip. Hashing here first and
100
+ # handing SubCache an already-hashed value made it hash the
101
+ # digest a second time, landing on a key nothing had ever
102
+ # written to, so logout silently failed to evict the entry.
103
+ token = payload.respond_to?(:session_token) ? payload.session_token : nil
104
+ if token && !token.to_s.empty?
105
+ cache.identity.invalidate(token.to_s)
106
+ else
107
+ bump_subject(cache, subject_id(payload))
108
+ end
109
+ else
110
+ # A `_User` write gives a user id, but identity entries are
111
+ # keyed by session token and no reverse map exists. Bumping a
112
+ # per-user generation invalidates every one of that user's
113
+ # entries in O(1), including tokens this process has never
114
+ # resolved, and without Parse Server's master-key `_Session`
115
+ # query.
116
+ bump_subject(cache, subject_id(payload))
117
+ end
118
+ end
119
+ true
120
+ end
121
+ [type, class_name]
122
+ end
123
+ end
124
+
125
+ # These triggers fire AFTER the write has committed, so raising here
126
+ # turns an already-successful save into a 500 for the client. A cache
127
+ # backend that is down must degrade to TTL-bounded staleness, never to
128
+ # a failed application request. The error is reported without its
129
+ # message, which can carry a key and therefore a session token.
130
+ def guard
131
+ yield
132
+ rescue StandardError => e
133
+ warn "[Parse::Cache::Invalidation] invalidation failed: #{e.class}"
134
+ if defined?(ActiveSupport::Notifications)
135
+ begin
136
+ ActiveSupport::Notifications.instrument(
137
+ "parse.cache.invalidation_error", error: e.class.name,
138
+ )
139
+ rescue StandardError
140
+ nil
141
+ end
142
+ end
143
+ nil
144
+ end
145
+
146
+ def bump_subject(cache, user_id)
147
+ return if user_id.nil? || user_id.to_s.empty?
148
+ cache.identity.bump_generation(user_id.to_s)
149
+ end
150
+
151
+ # The affected user's id. For a `_User` trigger that is the object
152
+ # itself; for a session it is the session's user pointer.
153
+ def subject_id(payload)
154
+ object = payload.respond_to?(:parse_object) ? payload.parse_object : nil
155
+ return nil if object.nil?
156
+ if object.respond_to?(:id) && payload.respond_to?(:parse_class) &&
157
+ payload.parse_class == USER_CLASS
158
+ return object.id
159
+ end
160
+ user = object.respond_to?(:user) ? object.user : nil
161
+ return user.id if user.respond_to?(:id)
162
+ object.respond_to?(:id) ? object.id : nil
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end