parse-stack-next 5.8.0 → 5.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +630 -0
  3. data/README.md +31 -19
  4. data/docs/caching.md +90 -5
  5. data/docs/mcp_guide.md +22 -5
  6. data/docs/mongodb_direct_guide.md +29 -0
  7. data/docs/webhooks_guide.md +94 -9
  8. data/lib/parse/acl_scope.rb +71 -9
  9. data/lib/parse/agent/constraint_translator.rb +22 -21
  10. data/lib/parse/agent/describe.rb +8 -2
  11. data/lib/parse/agent/errors.rb +17 -0
  12. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  13. data/lib/parse/agent/mcp_server.rb +47 -6
  14. data/lib/parse/agent.rb +521 -70
  15. data/lib/parse/api/batch.rb +70 -2
  16. data/lib/parse/atlas_search/index_manager.rb +5 -1
  17. data/lib/parse/atlas_search.rb +50 -1
  18. data/lib/parse/authorization.rb +235 -3
  19. data/lib/parse/cache/sub_cache.rb +26 -0
  20. data/lib/parse/client/authentication.rb +19 -1
  21. data/lib/parse/client/batch.rb +263 -11
  22. data/lib/parse/client/body_builder.rb +100 -15
  23. data/lib/parse/client/caching.rb +53 -3
  24. data/lib/parse/client/request.rb +59 -0
  25. data/lib/parse/client/response.rb +63 -5
  26. data/lib/parse/client.rb +173 -33
  27. data/lib/parse/clp_scope.rb +37 -3
  28. data/lib/parse/live_query/client.rb +142 -11
  29. data/lib/parse/lock_backend.rb +4 -3
  30. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  31. data/lib/parse/model/associations/has_many.rb +11 -0
  32. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  33. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  34. data/lib/parse/model/classes/role.rb +23 -0
  35. data/lib/parse/model/classes/session.rb +307 -19
  36. data/lib/parse/model/classes/user.rb +33 -12
  37. data/lib/parse/model/core/actions.rb +41 -5
  38. data/lib/parse/model/core/fetching.rb +29 -2
  39. data/lib/parse/model/core/field_guards.rb +16 -8
  40. data/lib/parse/model/file.rb +96 -19
  41. data/lib/parse/model/object.rb +52 -1
  42. data/lib/parse/model/push.rb +11 -1
  43. data/lib/parse/mongodb.rb +43 -2
  44. data/lib/parse/pipeline_security.rb +157 -21
  45. data/lib/parse/query/constraint.rb +1 -1
  46. data/lib/parse/query/constraints.rb +665 -41
  47. data/lib/parse/query.rb +802 -60
  48. data/lib/parse/stack/tasks.rb +1 -1
  49. data/lib/parse/stack/version.rb +1 -1
  50. data/lib/parse/stack.rb +23 -0
  51. data/lib/parse/vector_search/hybrid.rb +78 -16
  52. data/lib/parse/vector_search.rb +215 -7
  53. data/lib/parse/webhooks/payload.rb +82 -8
  54. data/lib/parse/webhooks/registration.rb +26 -6
  55. data/lib/parse/webhooks/replay_protection.rb +19 -9
  56. data/lib/parse/webhooks.rb +329 -13
  57. metadata +1 -1
@@ -1,10 +1,16 @@
1
1
  # encoding: UTF-8
2
2
  # frozen_string_literal: true
3
3
 
4
+ require "openssl"
5
+ require "securerandom"
4
6
  require_relative "request"
5
7
  require_relative "response"
6
8
 
7
9
  module Parse
10
+ # Declared here because this file loads before lib/parse/client.rb, which
11
+ # reopens it with the same superclass and defines its subclasses.
12
+ class Error < StandardError; end
13
+
8
14
  # Create a new batch operation.
9
15
  # @param reqs [Array<Parse::Request>] a set of requests to batch.
10
16
  # @return [BatchOperation] a new {BatchOperation} with the given change requests.
@@ -36,6 +42,50 @@ module Parse
36
42
  class BatchOperation
37
43
  include Enumerable
38
44
 
45
+ # Raised when a batch would send requests built for different
46
+ # credentials (a different client, an explicit session token, or an
47
+ # explicit `use_master_key:`) as one transaction. Parse Server runs every
48
+ # sub-request of a `POST /batch` under that call's own credentials, so a
49
+ # mixed transaction cannot honor each request's authority. Nothing is
50
+ # sent when this is raised.
51
+ class MixedAuthorityError < Parse::Error; end
52
+
53
+ # @!visibility private
54
+ # Per-process random key for {.credential_fingerprint}'s digests, so a
55
+ # fingerprint cannot be compared against a precomputed digest of a key.
56
+ FINGERPRINT_KEY = SecureRandom.bytes(32).freeze
57
+
58
+ # @!visibility private
59
+ # A comparable identity for the credentials a client sends: server URL,
60
+ # application id, and keyed digests (HMAC-SHA256 under a per-process
61
+ # random key) of the REST key, master key, and bound session token. Two
62
+ # client objects with the same configuration (for example a class client
63
+ # memoized before a second `Parse.setup`) have the same fingerprint and
64
+ # are batched together. The fingerprint never holds a secret.
65
+ #
66
+ # With `session_scoped: true` the master key and bound session token are
67
+ # left out: a request that names its own session token (or an explicit
68
+ # blank one) never sends the master key or the client's bound token, so
69
+ # those do not change its credentials.
70
+ # @note Only these settings are compared. Custom Faraday middleware or
71
+ # headers added in a client's setup block are not part of the
72
+ # fingerprint, so two clients that differ only there are treated as
73
+ # the same credentials and their requests may be sent through either.
74
+ # @param c [Parse::Client]
75
+ # @param session_scoped [Boolean]
76
+ # @return [Array]
77
+ def self.credential_fingerprint(c, session_scoped: false)
78
+ return [:client, c.object_id] unless c.respond_to?(:application_id) && c.respond_to?(:server_url)
79
+ digest = lambda do |v|
80
+ v.nil? || v.to_s.empty? ? nil : OpenSSL::HMAC.hexdigest("SHA256", FINGERPRINT_KEY, v.to_s)
81
+ end
82
+ base = [c.server_url.to_s.sub(%r{/+\z}, ""), c.application_id.to_s,
83
+ digest.call(c.respond_to?(:api_key) ? c.api_key : nil)]
84
+ return base + [:session_scoped] if session_scoped
85
+ bound = c.respond_to?(:session_token) ? c.session_token : nil
86
+ base + [digest.call(c.respond_to?(:master_key) ? c.master_key : nil), digest.call(bound)]
87
+ end
88
+
39
89
  # Default number of threads used to dispatch batch segments concurrently.
40
90
  # Raise via `Parse::BatchOperation.parallelism = N` (or pass `parallelism:`
41
91
  # to `#submit`) for higher throughput on bulk writes; 2 is intentionally
@@ -60,11 +110,38 @@ module Parse
60
110
  # @return [Boolean] whether this batch should be executed as a transaction.
61
111
  attr_accessor :requests, :responses, :transaction
62
112
 
63
- # @return [Parse::Client] the client to be used for the request.
113
+ # @return [Parse::Client] the client used for requests that were not
114
+ # built for a specific client. In a batch with no client set
115
+ # explicitly, requests built from an object
116
+ # ({Parse::Object#change_requests}, {Parse::Object#destroy_request})
117
+ # carry their class's client and are sent through it.
64
118
  def client
65
119
  @client ||= Parse::Client.client
66
120
  end
67
121
 
122
+ # Set the client every request in this batch is sent through. An
123
+ # explicitly chosen client wins over the class client a request was
124
+ # built for, as {Parse::Client#request} ignores it for a single request;
125
+ # each request's own `session_token:` / `use_master_key:` options and
126
+ # headers still apply.
127
+ # @param c [Parse::Client]
128
+ def client=(c)
129
+ @client = c
130
+ @explicit_client = !c.nil?
131
+ end
132
+
133
+ # @!visibility private
134
+ # Credentials (`session_token:`, `use_master_key:`) applied to requests
135
+ # that name none of their own. Set by {Parse::API::Batch#batch_request}
136
+ # when it routes a mixed batch through {#submit}.
137
+ # @return [Hash]
138
+ def batch_defaults
139
+ @batch_defaults || {}
140
+ end
141
+
142
+ # @!visibility private
143
+ attr_writer :batch_defaults
144
+
68
145
  # @param reqs [Array<Parse::Request>] an array of requests.
69
146
  # @param transaction [Boolean] whether to execute as a transaction.
70
147
  def initialize(reqs = nil, transaction: false)
@@ -185,22 +262,48 @@ module Parse
185
262
  failure = nil
186
263
  return @responses if @requests.empty?
187
264
 
265
+ # Parse Server runs every sub-request under the credentials of the one
266
+ # `POST /batch` call, so requests built for different credentials are
267
+ # sent as separate calls, each with its own client and options. This
268
+ # is decided before anything is sent.
269
+ groups = authority_groups(client, batch_defaults, force_client: @explicit_client)
270
+
188
271
  if @transaction
272
+ if groups.size > 1
273
+ raise MixedAuthorityError,
274
+ "A transaction cannot mix requests built for different credentials " \
275
+ "(#{groups.size} distinct clients or session/master-key options). Parse Server " \
276
+ "runs a batch under one credential, so it cannot honor each request's own. " \
277
+ "Save these objects in separate transactions."
278
+ end
279
+ group = groups.first
189
280
  # One request, one transaction. Exceptions propagate unchanged so the
190
281
  # caller can roll back its local state.
191
- @responses = align_responses(@requests, client.batch_request(self))
282
+ result = group[:client].batch_request(self, **group[:opts])
283
+ @responses = align_responses(@requests, result)
192
284
  else
193
285
  segment = 50 if segment.nil? || segment < 1
194
286
  parallelism = 1 if parallelism.nil? || parallelism < 1
195
- slices = @requests.each_slice(segment).to_a
287
+ # Each slice holds requests of one authority group, with their
288
+ # positions in the batch, so responses go back in request order.
289
+ slices = groups.flat_map do |g|
290
+ g[:entries].each_slice(segment).map { |entries| [g, entries] }
291
+ end
196
292
  outcomes = slices.threaded_map(parallelism) do |slice|
293
+ g, entries = slice
294
+ reqs = entries.map(&:last)
197
295
  begin
198
- [align_responses(slice, client.batch_request(BatchOperation.new(slice))), nil]
296
+ chunk = BatchOperation.new
297
+ chunk.requests = reqs
298
+ [entries, align_responses(reqs, g[:client].batch_request(chunk, **g[:opts])), nil]
199
299
  rescue StandardError => e
200
- [Array.new(slice.size) { exception_response(e) }, e]
300
+ [entries, Array.new(reqs.size) { exception_response(e) }, e]
201
301
  end
202
302
  end
203
- @responses = outcomes.flat_map(&:first)
303
+ @responses = Array.new(@requests.size)
304
+ outcomes.each do |entries, responses, _error|
305
+ entries.each_with_index { |(index, _req), i| @responses[index] = responses[i] }
306
+ end
204
307
  failure = outcomes.map(&:last).compact.first
205
308
  end
206
309
 
@@ -213,6 +316,93 @@ module Parse
213
316
 
214
317
  private
215
318
 
319
+ # Group the requests by the credentials they would have as single
320
+ # requests: the client (a request's own class client in an implicit
321
+ # batch, else this batch's), plus the request's session token,
322
+ # `use_master_key:`, and master-key suppression from
323
+ # {Parse::Request#explicit_authority}. Requests with no explicit
324
+ # authority on this batch's client form one group sent with no extra
325
+ # options, so an ambient `Parse.with_session`, `Parse.client_mode`, or
326
+ # `Parse.without_master_key` applies exactly as it does to a single
327
+ # request.
328
+ #
329
+ # `default_opts` (call options from {Parse::API::Batch#batch_request})
330
+ # apply to a request that names no credentials of its own. Narrowing
331
+ # call options always win: `use_master_key: false` and master-key
332
+ # suppression from the call override a request's own
333
+ # `use_master_key: true`. A call option never widens a request.
334
+ # @param default_client [Parse::Client] client for requests built for
335
+ # none, and for every request when `force_client` is true.
336
+ # @param default_opts [Hash] `session_token:` / `use_master_key:` /
337
+ # `suppress_master_key:` call options.
338
+ # @param force_client [Boolean] send every request through
339
+ # `default_client` (the caller chose the client explicitly).
340
+ # @return [Array<Hash>] groups in first-appearance order, each with
341
+ # `:client`, `:opts`, and `:entries` (`[index, request]` pairs).
342
+ # @!visibility private
343
+ def authority_groups(default_client = client, default_opts = batch_defaults, force_client: false)
344
+ defaults = default_opts.is_a?(Hash) ? default_opts : {}
345
+ default_token = self.class.resolve_session_option(defaults, :session_token)
346
+ fingerprints = {}
347
+ fingerprint = lambda do |c, scoped|
348
+ fingerprints[[c.object_id, scoped]] ||= self.class.credential_fingerprint(c, session_scoped: scoped)
349
+ end
350
+ groups = {}
351
+ @requests.each_with_index do |req, index|
352
+ target = if force_client
353
+ default_client
354
+ elsif req.respond_to?(:client) && req.client
355
+ req.client
356
+ else
357
+ default_client
358
+ end
359
+ # Credentials resolve as they do for a single request: options, then
360
+ # the session-token and master-key-suppression headers.
361
+ named = req.respond_to?(:explicit_authority) ? req.explicit_authority : {}
362
+ token = named.key?(:session_token) ? named[:session_token] : default_token
363
+ master = named.key?(:use_master_key) ? named[:use_master_key] : defaults[:use_master_key]
364
+ master = nil unless master == true || master == false
365
+ # Narrowing call options win over a request's own master opt-in.
366
+ master = false if defaults[:use_master_key] == false
367
+ suppress = named[:suppress_master_key] == true || defaults[:suppress_master_key] == true
368
+ key = [fingerprint.call(target, !token.nil?), token, master, suppress]
369
+ existing = groups[key]
370
+ if existing && !existing[:client].equal?(default_client) && target.equal?(default_client)
371
+ # Same credentials as an earlier client object: send through the
372
+ # batch's own client.
373
+ existing[:client] = default_client
374
+ end
375
+ group = groups[key] ||= begin
376
+ call_opts = {}
377
+ call_opts[:session_token] = token unless token.nil?
378
+ call_opts[:use_master_key] = master unless master.nil?
379
+ call_opts[:suppress_master_key] = true if suppress
380
+ { client: target, opts: call_opts, entries: [] }
381
+ end
382
+ group[:entries] << [index, req]
383
+ end
384
+ groups.values
385
+ end
386
+
387
+ # @!visibility private
388
+ # The session token named under `key` in `opts`, resolved the way
389
+ # {Parse::Client#request} resolves it: a user object yields its token,
390
+ # and a value that is present but resolves to nil or blank becomes `""`
391
+ # so the explicit-blank branch fails closed (no master key, no ambient
392
+ # or bound token). nil when the option is absent or literally nil.
393
+ # @param opts [Hash]
394
+ # @param key [Symbol]
395
+ # @return [String, nil]
396
+ def self.resolve_session_option(opts, key)
397
+ return nil unless opts.is_a?(Hash)
398
+ raw = opts[key]
399
+ return nil if raw.nil?
400
+ token = raw.respond_to?(:session_token) ? raw.session_token : raw
401
+ return "" if token.nil?
402
+ token = token.to_s
403
+ token.strip.empty? ? "" : token
404
+ end
405
+
216
406
  # Whether `req` repeats a request already in the batch for the same
217
407
  # tagged object.
218
408
  def duplicate_object_request?(req)
@@ -266,33 +456,61 @@ class Array
266
456
  # Each object whose delete succeeds has its local state updated the same
267
457
  # way {Parse::Object#destroy} updates it. Objects whose delete fails are
268
458
  # left untouched; inspect the returned batch's responses to find them.
269
- # Destroy callbacks are not run.
459
+ # Destroy callbacks are not run. Every {Parse::Session} and {Parse::User}
460
+ # whose delete succeeded, or reported "object not found" (the row is
461
+ # already gone, for example revoked elsewhere), is dropped from its
462
+ # client's identity plane, as their single-object destroy does.
270
463
  # @example
271
464
  # # assume Post and Author are Parse models
272
465
  # author = Author.first
273
466
  # posts = Post.all author: author
274
467
  # posts.destroy # batch destroy request
468
+ # @param session [String, #session_token, nil] send every delete as this
469
+ # user (ACL and CLP enforced) instead of each object's client default.
275
470
  # @return [Parse::BatchOperation] the batch operation performed.
276
471
  # @raise ArgumentError if the array is not empty and holds no Parse objects.
472
+ # @raise Parse::BatchOperation::MixedAuthorityError never for a destroy;
473
+ # objects bound to different clients are deleted in separate calls.
277
474
  # @see Parse::BatchOperation
278
- def destroy
475
+ def destroy(session: nil)
476
+ token = Parse::BatchOperation.session_token_for!(session)
279
477
  targets = select { |o| o.respond_to?(:destroy_request) }
280
478
  if targets.empty? && !empty?
281
479
  raise ArgumentError, "Array#destroy requires Parse::Object elements; " \
282
480
  "this array holds none (#{first.class})"
283
481
  end
482
+ # A session deleted from a pointer or a partial fetch carries no token or
483
+ # owner to drop from the identity cache; look them up first, in one
484
+ # query per client.
485
+ Parse::Session.send(:_preload_identity_for_destroy!, targets, session_token: token) if defined?(Parse::Session)
486
+ _destroy_batch(targets, token)
487
+ ensure
488
+ # A looked-up session token is a live credential; never leave it on an
489
+ # object whose delete was skipped or whose batch raised.
490
+ targets&.each { |o| o.send(:_clear_identity_for_destroy!) if _batch_identity_hook?(o, :_clear_identity_for_destroy!) }
491
+ end
492
+
493
+ # @!visibility private
494
+ def _destroy_batch(targets, token = nil)
284
495
  batch = Parse::BatchOperation.new
285
496
  objects = {}
286
497
  targets.each do |o|
287
498
  next if objects.key?(o.object_id)
288
499
  r = o.destroy_request
289
500
  next if r.nil?
501
+ r.opts[:session_token] = token if token
290
502
  objects[o.object_id] = o
291
503
  batch.add(r)
292
504
  end
293
505
  batch.submit do |request, response|
294
506
  o = objects[request.tag]
295
- next unless o && response.respond_to?(:success?) && response.success?
507
+ next unless o
508
+ # Sessions and users drop their identity-plane entries when the delete
509
+ # applied or the row is already gone ("object not found"), so a revoked
510
+ # token stops resolving now. They decide from the response; a denied
511
+ # delete leaves the cache alone.
512
+ o.send(:_after_batch_destroy, response) if _batch_identity_hook?(o, :_after_batch_destroy)
513
+ next unless response.respond_to?(:success?) && response.success?
296
514
  # Mirror Parse::Object#destroy: keep the id and mark the object
297
515
  # destroyed so it reports `destroyed?` and a later save refuses.
298
516
  o.instance_variable_set(:@_destroyed, true)
@@ -300,6 +518,17 @@ class Array
300
518
  end
301
519
  batch
302
520
  end
521
+ private :_destroy_batch
522
+
523
+ # Whether `o`'s class defines the internal (non-public) identity hook
524
+ # `name`. Checked on the class rather than with `respond_to?(name, true)`
525
+ # so a duck-typed element overriding `respond_to?` cannot break the batch.
526
+ # @!visibility private
527
+ def _batch_identity_hook?(o, name)
528
+ klass = o.class
529
+ klass.private_method_defined?(name) || klass.method_defined?(name)
530
+ end
531
+ private :_batch_identity_hook?
303
532
 
304
533
  # Do not alias method as :delete is already part of array.
305
534
  # alias_method :delete, :destroy
@@ -323,16 +552,22 @@ class Array
323
552
  # objects back to the original ones submitted. If you don't need the original objects
324
553
  # to be updated with the changes, set this to false for improved performance.
325
554
  # @param force [Boolean] Do not skip objects that do not have pending changes (dirty tracking).
555
+ # @param session [String, #session_token, nil] send every write as this
556
+ # user (ACL and CLP enforced) instead of each object's client default.
326
557
  # @example
327
558
  # # assume Post and Author are Parse models
328
559
  # author = Author.first
329
560
  # posts = Post.first 100
330
561
  # posts.each { |post| post.author = author }
331
562
  # posts.save # batch save
563
+ # @note Each write is sent with the credentials of its object's class
564
+ # client, as a single {Parse::Object#save} is. Objects bound to different
565
+ # clients are saved in separate batch calls.
332
566
  # @return [Parse::BatchOperation] the batch operation performed.
333
567
  # @raise ArgumentError if the array is not empty and holds no Parse objects.
334
568
  # @see Parse::BatchOperation
335
- def save(merge: true, force: false)
569
+ def save(merge: true, force: false, session: nil)
570
+ token = Parse::BatchOperation.session_token_for!(session)
336
571
  targets = select { |o| o.is_a?(Parse::Object) }
337
572
  if targets.empty? && !empty?
338
573
  raise ArgumentError, "Array#save requires Parse::Object elements; " \
@@ -343,7 +578,9 @@ class Array
343
578
  targets.each do |o|
344
579
  next if objects.key?(o.object_id)
345
580
  objects[o.object_id] = o
346
- batch.add o.change_requests(force)
581
+ reqs = o.change_requests(force)
582
+ reqs.each { |r| r.opts[:session_token] = token } if token
583
+ batch.add reqs
347
584
  end
348
585
  if merge == false
349
586
  batch.submit
@@ -367,6 +604,21 @@ end
367
604
 
368
605
  module Parse
369
606
  class BatchOperation
607
+ # @!visibility private
608
+ # The session token named by a `session:` argument, or nil when none was
609
+ # given. A blank token is refused rather than treated as "no session".
610
+ # @param session [String, #session_token, nil]
611
+ # @return [String, nil]
612
+ # @raise ArgumentError for a blank or unusable session.
613
+ def self.session_token_for!(session)
614
+ return nil if session.nil?
615
+ token = session.respond_to?(:session_token) ? session.session_token : session
616
+ unless token.is_a?(String) && !token.strip.empty?
617
+ raise ArgumentError, "session: must be a session token or a user with one"
618
+ end
619
+ token
620
+ end
621
+
370
622
  # @!visibility private
371
623
  # Apply the batch responses for one object's requests to that object.
372
624
  # @param obj [Parse::Object] the object that was saved.
@@ -36,12 +36,41 @@ module Parse
36
36
  # Maximum url length for most server requests before HTTP Method Override is used.
37
37
  MAX_URL_LENGTH = 2_000.freeze
38
38
  # Fields that should be redacted from log output.
39
+ #
40
+ # Covers MFA material (`recovery` codes and the `secret` returned by
41
+ # MFA enrollment, also under `authDataResponse`) and the storage-form
42
+ # credential columns a raw MongoDB document carries (`_session_token`,
43
+ # `_hashed_password`, and the reset/verify tokens). Keys starting with
44
+ # {SENSITIVE_KEY_PREFIXES} (per-provider `_auth_data_<provider>`
45
+ # columns) are redacted too.
39
46
  SENSITIVE_FIELDS = %w[
40
47
  password token sessionToken session_token access_token authData
41
48
  masterKey master_key apiKey api_key clientKey client_key
42
49
  javascriptKey javascript_key refreshToken refresh_token
50
+ recovery recoveryCodes recovery_codes authDataResponse secret
51
+ _session_token _hashed_password _email_verify_token
52
+ _perishable_token _password_history
53
+ ].freeze
54
+ # Key prefixes whose values are always redacted.
55
+ SENSITIVE_KEY_PREFIXES = %w[_auth_data_].freeze
56
+ # A credential value: a double- or single-quoted string (with escapes),
57
+ # taken whole so a quoted value with spaces is redacted entirely, or an
58
+ # unquoted run up to a delimiter.
59
+ SENSITIVE_VALUE = /(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^"'&\s,}\]]+)/
60
+ SENSITIVE_PATTERN = /(#{(SENSITIVE_FIELDS + SENSITIVE_KEY_PREFIXES.map { |p| "#{p}\\w+" }).join("|")})(["']?\s*[=:>]\s*)(#{SENSITIVE_VALUE.source})/i
61
+ # Credential header names whose value is redacted when they appear as
62
+ # text (`X-Parse-Master-Key: mk`, `Authorization: Bearer x`), for
63
+ # example in an error message or a pasted request. Real request
64
+ # headers are redacted through {REDACTED_HEADERS}.
65
+ SENSITIVE_HEADER_NAMES = %w[
66
+ X-Parse-Master-Key X-Parse-REST-API-Key X-Parse-Session-Token
67
+ X-Parse-Javascript-Key X-Parse-Client-Key X-Parse-Webhook-Key
68
+ Authorization
43
69
  ].freeze
44
- SENSITIVE_PATTERN = /(#{SENSITIVE_FIELDS.join("|")})(["']?\s*[=:>]\s*["']?)([^"&\s,}\]]+)/i
70
+ # Header-shaped text: a credential header name, a `:` or `=`
71
+ # separator, an optional auth scheme (`Bearer`, `Basic`, `Token`),
72
+ # then the value.
73
+ SENSITIVE_HEADER_PATTERN = /(#{SENSITIVE_HEADER_NAMES.map { |n| Regexp.escape(n) }.join("|")})(["']?\s*[=:]\s*)((?:bearer|basic|token)\s+)?(#{SENSITIVE_VALUE.source})/i
45
74
  # Lookup set of sensitive field names for structural (JSON) redaction
46
75
  # — case-insensitive match on the key, not the value. Walks the parsed
47
76
  # structure so nested objects like {"password":{"nested":"value"}}
@@ -139,32 +168,81 @@ module Parse
139
168
  def self.redact(str)
140
169
  s = str.to_s
141
170
  return s if s.empty?
142
- after_structural = s
143
171
  if (parsed = try_parse_json(s))
144
172
  scrubbed = scrub_sensitive!(parsed)
145
173
  compact_vectors!(scrubbed)
146
174
  begin
147
- after_structural = scrubbed.to_json
175
+ # Text patterns run on each string value before encoding, never
176
+ # on the encoded JSON: escaped quotes inside a value
177
+ # (`password=\"x\"`) would defeat them and could corrupt the JSON.
178
+ return redact_string_values!(scrubbed).to_json
148
179
  rescue StandardError
149
- after_structural = s
180
+ # Fall through to the text pass on the original string.
150
181
  end
151
182
  end
152
- after_structural.gsub(SENSITIVE_PATTERN) do
183
+ redact_patterns(s)
184
+ end
185
+
186
+ # Apply {redact_patterns} to every String value in a parsed JSON
187
+ # structure, in place. Keys are left alone (the structural pass already
188
+ # replaced the values of sensitive keys).
189
+ # @param obj [Object] a parsed JSON value.
190
+ # @return [Object] obj.
191
+ def self.redact_string_values!(obj)
192
+ case obj
193
+ when Hash
194
+ obj.each { |k, v| obj[k] = v.is_a?(String) ? redact_patterns(v) : redact_string_values!(v) }
195
+ when Array
196
+ obj.map! { |v| v.is_a?(String) ? redact_patterns(v) : redact_string_values!(v) }
197
+ end
198
+ obj
199
+ end
200
+
201
+ # The regex pass of {redact} on its own: replaces values that follow
202
+ # a credential field name (`password=x`, `"sessionToken":"r:x"`) or a
203
+ # credential header name (`X-Parse-Master-Key: mk`,
204
+ # `Authorization: Bearer x`). Use it on text that is not JSON, or on
205
+ # JSON that has already been scrubbed structurally.
206
+ # @param str [String]
207
+ # @return [String]
208
+ def self.redact_patterns(str)
209
+ s = str.to_s
210
+ return s if s.empty?
211
+ s = s.gsub(SENSITIVE_PATTERN) do
153
212
  key_part = $1
154
213
  sep_part = $2
155
214
  val_part = $3
156
- # Skip values that the structural pass already redacted —
157
- # otherwise the regex value-class `[^"&\s,}\]]+` stops at the
158
- # bracket and we end up with `[FILTERED]]` from the trailing
159
- # close-bracket left over from `"[FILTERED]"`.
160
- if val_part == "[FILTERED" || val_part == REDACTED_PLACEHOLDER
161
- "#{key_part}#{sep_part}#{val_part}"
162
- else
163
- "#{key_part}#{sep_part}#{REDACTED_PLACEHOLDER}"
164
- end
215
+ # Skip values that the structural pass already redacted. The
216
+ # value class `[^"&\s,}\]]+` stops at the bracket, so without
217
+ # this a second pass would turn `"[FILTERED]"` into
218
+ # `[FILTERED]]`.
219
+ "#{key_part}#{sep_part}#{redacted_value(val_part)}"
220
+ end
221
+ s.gsub(SENSITIVE_HEADER_PATTERN) do
222
+ name_part = $1
223
+ sep_part = $2
224
+ scheme_part = $3
225
+ val_part = $4
226
+ "#{name_part}#{sep_part}#{scheme_part}#{redacted_value(val_part)}"
165
227
  end
166
228
  end
167
229
 
230
+ # @!visibility private
231
+ def self.already_redacted?(value)
232
+ value == "[FILTERED" || value == REDACTED_PLACEHOLDER
233
+ end
234
+
235
+ # @!visibility private
236
+ # The placeholder for a matched value, keeping the value's own quotes
237
+ # so quoted text (and JSON) stays well formed. A value that is already
238
+ # the placeholder is left as is.
239
+ def self.redacted_value(value)
240
+ quote = value[0] if value.length >= 2 && (value[0] == '"' || value[0] == "'") && value[-1] == value[0]
241
+ inner = quote ? value[1..-2] : value
242
+ return value if already_redacted?(inner)
243
+ quote ? "#{quote}#{REDACTED_PLACEHOLDER}#{quote}" : REDACTED_PLACEHOLDER
244
+ end
245
+
168
246
  # @!visibility private
169
247
  def self.try_parse_json(str)
170
248
  # Find first non-whitespace byte; allow leading whitespace and BOM.
@@ -178,6 +256,13 @@ module Parse
178
256
  nil
179
257
  end
180
258
 
259
+ # Whether a JSON key names a credential whose value must be redacted.
260
+ # @!visibility private
261
+ def self.sensitive_key?(key)
262
+ k = key.to_s.downcase
263
+ SENSITIVE_FIELDS_SET.include?(k) || SENSITIVE_KEY_PREFIXES.any? { |p| k.start_with?(p) }
264
+ end
265
+
181
266
  # @!visibility private
182
267
  # Recursively walks a parsed JSON structure replacing values under any
183
268
  # sensitive key with the redaction placeholder. Returns the same node
@@ -190,7 +275,7 @@ module Parse
190
275
  case node
191
276
  when Hash
192
277
  node.each do |key, value|
193
- if key.is_a?(String) && SENSITIVE_FIELDS_SET.include?(key.downcase)
278
+ if key.is_a?(String) && sensitive_key?(key)
194
279
  node[key] = REDACTED_PLACEHOLDER
195
280
  elsif value.is_a?(Hash) || value.is_a?(Array)
196
281
  scrub_sensitive!(value)
@@ -73,6 +73,31 @@ module Parse
73
73
  # @return [Boolean] whether the logging should be enabled.
74
74
  attr_accessor :logging
75
75
 
76
+ # @!attribute cache_session_requests
77
+ # Whether reads made with a session token are cached. Off by default.
78
+ #
79
+ # A cached session read is answered without contacting Parse Server,
80
+ # so it cannot notice that the session was revoked (logout,
81
+ # {Parse::Session#destroy}, `logout_all!`, a password change) or that
82
+ # the user lost a role or row access through a change the SDK did not
83
+ # make. With this off, every session read reaches Parse Server, which
84
+ # checks the token and the current ACLs and CLPs each time. Master-key
85
+ # and anonymous reads are cached as before. Turning this on trades that
86
+ # guarantee for speed: a revoked or narrowed session keeps reading its
87
+ # cached responses until they expire.
88
+ #
89
+ # Only `true` enables it; any other value (including the String
90
+ # `"true"` read from an environment variable) leaves session reads
91
+ # uncached. A client can override this default with
92
+ # `Parse.setup(cache_session_requests: true)` (the middleware option
93
+ # of the same name).
94
+ # @return [Boolean]
95
+ attr_writer :cache_session_requests
96
+
97
+ def cache_session_requests
98
+ @cache_session_requests == true
99
+ end
100
+
76
101
  def enabled
77
102
  @enabled = true if @enabled.nil?
78
103
  @enabled
@@ -107,6 +132,10 @@ module Parse
107
132
  @opts = { expires: 0 }
108
133
  @opts.merge!(opts) if opts.is_a?(Hash)
109
134
  @expires = @opts[:expires]
135
+ # Per-middleware override of {.cache_session_requests}; nil defers to
136
+ # the class-level default.
137
+ @cache_session_requests =
138
+ @opts.key?(:cache_session_requests) ? (@opts[:cache_session_requests] == true) : nil
110
139
  # Optional cache key namespace so two Parse apps sharing one Redis don't
111
140
  # collide (e.g. `mk:/classes/Song/abc` is the same path for both apps).
112
141
  # When set, keys become `<namespace>:<existing-prefix>:<url>`. Empty
@@ -186,6 +215,20 @@ module Parse
186
215
  # time a long query ran.
187
216
  return @app.call(env) if method != :get && @request_headers[METHOD_OVERRIDE].to_s.casecmp?("GET")
188
217
 
218
+ # A session read is never read from or stored in the cache unless the
219
+ # application opted in (see {.cache_session_requests}): a cached answer
220
+ # would keep serving a revoked or narrowed session until it expired.
221
+ # The token header is set by the request layer from the effective
222
+ # session (an explicit `session_token:`, `Parse.with_session`, or a
223
+ # session-bound client), so this one check covers all three. Writes
224
+ # made with a session still run the invalidation below.
225
+ # Read the ambient cache tenant first so the bypass event carries it.
226
+ @cache_tenant = Parse.respond_to?(:current_cache_tenant) ? Parse.current_cache_tenant : nil
227
+ if method == :get && @request_headers.key?(SESSION_TOKEN) && !cache_session_requests?
228
+ instrument_cache(:bypass, method: method, url_path: url.path, reason: :session)
229
+ return @app.call(env)
230
+ end
231
+
189
232
  @cache_key = url.to_s
190
233
 
191
234
  # Auth discriminator. A master-key request bypasses ACL, CLP and
@@ -232,7 +275,6 @@ module Parse
232
275
  # and from `mk:`, so legacy cache entries written before the
233
276
  # tenant feature don't accidentally re-hydrate into a tenanted
234
277
  # request and vice versa.
235
- @cache_tenant = Parse.respond_to?(:current_cache_tenant) ? Parse.current_cache_tenant : nil
236
278
  if @cache_tenant
237
279
  @cache_key = "T:#{@cache_tenant}:#{@cache_key}"
238
280
  @old_shape_key = "T:#{@cache_tenant}:#{@old_shape_key}"
@@ -438,8 +480,9 @@ module Parse
438
480
  # Emit an ActiveSupport::Notifications event under the `parse.cache.*`
439
481
  # namespace.
440
482
  #
441
- # **Payload shape (stable):** `{ event:, namespace:, method:, url_path:,
442
- # [reason:], [duration_ms:], [error:] }`.
483
+ # **Payload shape (stable):** `{ event:, namespace:, cache_tenant:,
484
+ # method:, url_path:, [reason:], [duration_ms:], [error:] }`.
485
+ # `cache_tenant` is the active `Parse.with_cache_tenant` value, or nil.
443
486
  #
444
487
  # **Security invariants:**
445
488
  # - The cache key is NEVER emitted. The key contains a hashed
@@ -472,6 +515,13 @@ module Parse
472
515
  ActiveSupport::Notifications.instrument("parse.cache.#{event}", payload)
473
516
  end
474
517
 
518
+ # Whether this middleware caches session reads: its own
519
+ # `cache_session_requests:` option when given, else the class default.
520
+ # @!visibility private
521
+ def cache_session_requests?
522
+ @cache_session_requests.nil? ? self.class.cache_session_requests : @cache_session_requests
523
+ end
524
+
475
525
  # Delete the canonical cache_key plus its legacy un-namespaced and
476
526
  # master-key-prefixed variants. Called on both GET misses (defensive
477
527
  # cleanup of stale pre-namespace entries) and non-GET writes (cache