parse-stack-next 5.8.1 → 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.
@@ -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)
@@ -275,10 +465,15 @@ class Array
275
465
  # author = Author.first
276
466
  # posts = Post.all author: author
277
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.
278
470
  # @return [Parse::BatchOperation] the batch operation performed.
279
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.
280
474
  # @see Parse::BatchOperation
281
- def destroy
475
+ def destroy(session: nil)
476
+ token = Parse::BatchOperation.session_token_for!(session)
282
477
  targets = select { |o| o.respond_to?(:destroy_request) }
283
478
  if targets.empty? && !empty?
284
479
  raise ArgumentError, "Array#destroy requires Parse::Object elements; " \
@@ -287,8 +482,8 @@ class Array
287
482
  # A session deleted from a pointer or a partial fetch carries no token or
288
483
  # owner to drop from the identity cache; look them up first, in one
289
484
  # query per client.
290
- Parse::Session.send(:_preload_identity_for_destroy!, targets) if defined?(Parse::Session)
291
- _destroy_batch(targets)
485
+ Parse::Session.send(:_preload_identity_for_destroy!, targets, session_token: token) if defined?(Parse::Session)
486
+ _destroy_batch(targets, token)
292
487
  ensure
293
488
  # A looked-up session token is a live credential; never leave it on an
294
489
  # object whose delete was skipped or whose batch raised.
@@ -296,13 +491,14 @@ class Array
296
491
  end
297
492
 
298
493
  # @!visibility private
299
- def _destroy_batch(targets)
494
+ def _destroy_batch(targets, token = nil)
300
495
  batch = Parse::BatchOperation.new
301
496
  objects = {}
302
497
  targets.each do |o|
303
498
  next if objects.key?(o.object_id)
304
499
  r = o.destroy_request
305
500
  next if r.nil?
501
+ r.opts[:session_token] = token if token
306
502
  objects[o.object_id] = o
307
503
  batch.add(r)
308
504
  end
@@ -356,16 +552,22 @@ class Array
356
552
  # objects back to the original ones submitted. If you don't need the original objects
357
553
  # to be updated with the changes, set this to false for improved performance.
358
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.
359
557
  # @example
360
558
  # # assume Post and Author are Parse models
361
559
  # author = Author.first
362
560
  # posts = Post.first 100
363
561
  # posts.each { |post| post.author = author }
364
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.
365
566
  # @return [Parse::BatchOperation] the batch operation performed.
366
567
  # @raise ArgumentError if the array is not empty and holds no Parse objects.
367
568
  # @see Parse::BatchOperation
368
- def save(merge: true, force: false)
569
+ def save(merge: true, force: false, session: nil)
570
+ token = Parse::BatchOperation.session_token_for!(session)
369
571
  targets = select { |o| o.is_a?(Parse::Object) }
370
572
  if targets.empty? && !empty?
371
573
  raise ArgumentError, "Array#save requires Parse::Object elements; " \
@@ -376,7 +578,9 @@ class Array
376
578
  targets.each do |o|
377
579
  next if objects.key?(o.object_id)
378
580
  objects[o.object_id] = o
379
- 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
380
584
  end
381
585
  if merge == false
382
586
  batch.submit
@@ -400,6 +604,21 @@ end
400
604
 
401
605
  module Parse
402
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
+
403
622
  # @!visibility private
404
623
  # Apply the batch responses for one object's requests to that object.
405
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
@@ -41,6 +41,65 @@ module Parse
41
41
  # Used to correlate batching requests with their responses.
42
42
  attr_accessor :tag
43
43
 
44
+ # @!visibility private
45
+ # The client this request belongs to, when it was built for a specific
46
+ # one (an object's class client). A {Parse::BatchOperation} sends a
47
+ # request through this client, so a write built for a session-bound
48
+ # client is never batched through another client's credentials. nil
49
+ # means the batch's own client.
50
+ attr_accessor :client
51
+
52
+ # @!visibility private
53
+ # The credentials this request names explicitly, resolved the way
54
+ # {Parse::Client#request} resolves them for a single request: the
55
+ # `session_token:` option, else an `X-Parse-Session-Token` header; and
56
+ # the `use_master_key:` option; and `suppress_master_key: true` when the
57
+ # master-key suppression header is set. A key is absent when the request
58
+ # does not name it, so ambient context (`Parse.with_session`,
59
+ # `client_mode`, a bound client token) still applies.
60
+ #
61
+ # A `session_token:` option that is present but resolves to no token (a
62
+ # user object without one, or a blank string) is returned as `""`, so a
63
+ # batch fails closed the same way a single request does: no master key
64
+ # and no ambient or bound token. `use_master_key:` is returned only when
65
+ # it is exactly `true` or `false`; {Parse::Client#request} treats any
66
+ # other value as not set, and so does a batch.
67
+ # @return [Hash] with optional `:session_token` (String),
68
+ # `:use_master_key` (Boolean), and `:suppress_master_key` (true) keys.
69
+ def explicit_authority
70
+ o = opts.is_a?(Hash) ? opts : {}
71
+ result = {}
72
+ token = Parse::BatchOperation.resolve_session_option(o, :session_token)
73
+ if token.nil?
74
+ header_token = self.class.header_value(headers, Parse::Protocol::SESSION_TOKEN)
75
+ if header_token.is_a?(String)
76
+ token = header_token.strip.empty? ? "" : header_token
77
+ end
78
+ end
79
+ result[:session_token] = token unless token.nil?
80
+ master = o[:use_master_key]
81
+ result[:use_master_key] = master if master == true || master == false
82
+ # The suppression header wins over `use_master_key: true` in the
83
+ # authentication middleware, so a batch must carry it as a header
84
+ # rather than fold it into `use_master_key:` (which would also change
85
+ # how the ambient and bound session tokens apply).
86
+ if self.class.header_value(headers, Parse::Middleware::Authentication::DISABLE_MASTER_KEY).present?
87
+ result[:suppress_master_key] = true
88
+ end
89
+ result
90
+ end
91
+
92
+ # @!visibility private
93
+ # Case-insensitive header lookup.
94
+ # @return [Object, nil]
95
+ def self.header_value(headers, name)
96
+ return nil unless headers.is_a?(Hash)
97
+ return headers[name] if headers.key?(name)
98
+ wanted = name.to_s.downcase
99
+ headers.each { |k, v| return v if k.to_s.downcase == wanted }
100
+ nil
101
+ end
102
+
44
103
  # @!attribute [rw] request_id
45
104
  # @return [String] unique identifier for this request to enable idempotency
46
105
  attr_accessor :request_id