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.
@@ -264,19 +264,77 @@ module Parse
264
264
  self
265
265
  end
266
266
 
267
+ # A summary that never prints result values: login, signup, and
268
+ # `users/me` responses carry a live `sessionToken` (and sometimes
269
+ # `authData`), and any other row can carry private data, so inspect
270
+ # shows only the shape of the result.
267
271
  # @!visibility private
268
272
  def inspect
269
273
  if error?
270
- "#<#{self.class} @code=#{code} @error='#{error}'>"
274
+ "#<#{self.class} @code=#{code} @error='#{safe_error_text}' @http_status=#{http_status.inspect}>"
271
275
  else
272
- "#<#{self.class} @result='#{@result}'>"
276
+ "#<#{self.class} @http_status=#{http_status.inspect} @result=#{result_summary}>"
273
277
  end
274
278
  end
275
279
 
276
- # @return [String] JSON encoded object, or an error string.
280
+ # @return [String] JSON encoded object, or an error string. Credential
281
+ # fields (`sessionToken`, `password`, `authData`, MFA recovery codes
282
+ # and secrets, keys) are replaced with a placeholder, and credential
283
+ # text inside string values (`password=x`, `X-Parse-Master-Key: mk`)
284
+ # is filtered too, so printing the response with `to_s` or `inspect`
285
+ # does not leak a live session token. The error form has its text redacted and control characters
286
+ # escaped. `#result`, `#to_json`, and `#as_json` return the raw values
287
+ # by design; do not log those.
277
288
  def to_s
278
- return "[E-#{@code}] #{@request} : #{@error} (#{@http_status})" if error?
279
- @result.to_json
289
+ if error?
290
+ return "[E-#{@code}] #{safe_request_text} : #{safe_error_text} (#{@http_status})"
291
+ end
292
+ result = redacted_result
293
+ return Parse::Middleware::BodyBuilder.redact_patterns(result) if result.is_a?(String)
294
+ # Text patterns run on string values before encoding, never on the
295
+ # encoded JSON, so escaped quotes cannot defeat them or corrupt it.
296
+ Parse::Middleware::BodyBuilder.redact_string_values!(result).to_json
297
+ end
298
+
299
+ private
300
+
301
+ # Maximum error text length kept in `to_s` / `inspect`.
302
+ SAFE_ERROR_TEXT_LENGTH = 1_000
303
+
304
+ # The server's error text with credentials redacted, truncated, and
305
+ # control characters escaped, matching what the client logs.
306
+ def safe_error_text
307
+ text = Parse::Middleware::BodyBuilder.redact(@error.to_s)[0, SAFE_ERROR_TEXT_LENGTH]
308
+ Parse::TerminalSafe.sanitize_line(text)
309
+ rescue StandardError
310
+ "[unprintable error]"
311
+ end
312
+
313
+ # The request line (method and path, including any query string) with
314
+ # credentials redacted and control characters escaped.
315
+ def safe_request_text
316
+ Parse::TerminalSafe.sanitize_line(Parse::Middleware::BodyBuilder.redact(@request.to_s))
317
+ rescue StandardError
318
+ "[unprintable request]"
319
+ end
320
+
321
+ # Class and size of the result, without its values.
322
+ def result_summary
323
+ case @result
324
+ when Array then "Array(#{@result.size})"
325
+ when Hash then "Hash(#{@result.size} keys)"
326
+ when nil then "nil"
327
+ else @result.class.name
328
+ end
329
+ end
330
+
331
+ # A deep copy of the result with credential fields replaced.
332
+ def redacted_result
333
+ return @result unless @result.is_a?(Hash) || @result.is_a?(Array)
334
+ copy = JSON.parse(@result.to_json)
335
+ Parse::Middleware::BodyBuilder.scrub_sensitive!(copy)
336
+ rescue StandardError
337
+ "[unprintable result]"
280
338
  end
281
339
  end
282
340
  end
data/lib/parse/client.rb CHANGED
@@ -419,6 +419,39 @@ module Parse
419
419
  "session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
420
420
  end
421
421
 
422
+ # Redacted JSON form. ActiveSupport's default `as_json` serializes every
423
+ # instance variable, so a client in a JSON log or error-tracker context
424
+ # (or inside an object that serializes it, such as a {Parse::Agent})
425
+ # would emit the master key, REST key, and bound session token.
426
+ # @return [Hash]
427
+ def as_json(*)
428
+ {
429
+ "server_url" => @server_url,
430
+ "app_id" => @application_id,
431
+ "master_key" => @master_key ? "[FILTERED]" : nil,
432
+ "session_token" => @session_token ? "[FILTERED]" : nil,
433
+ }
434
+ end
435
+
436
+ # @return [String] the redacted {#as_json} summary as JSON.
437
+ def to_json(*args)
438
+ as_json.to_json(*args)
439
+ end
440
+
441
+ # YAML (Psych) serializes instance variables too; emit the redacted
442
+ # summary instead.
443
+ def encode_with(coder)
444
+ as_json.each { |k, v| coder[k] = v }
445
+ end
446
+
447
+ # A client holds the master key, REST key, and session tokens, so it is
448
+ # never marshaled (Rails.cache, DRb, or a job payload would store them in
449
+ # the clear). Store the configuration and build a new client instead.
450
+ # @raise [TypeError]
451
+ def marshal_dump
452
+ raise TypeError, "Parse::Client cannot be marshaled"
453
+ end
454
+
422
455
  # A NEW non-master {Parse::Client} that mirrors THIS client's connection
423
456
  # settings (`server_url` / `application_id` / `api_key`) but carries no
424
457
  # master key and binds `session_token`, so it acts on the server as that
@@ -645,6 +678,12 @@ module Parse
645
678
  # middleware. The default value is 3 seconds. If :expires is set to 0,
646
679
  # caching will be disabled. You can always clear the current state of the
647
680
  # cache using the clear_cache! method on your Parse::Client instance.
681
+ # @option opts [Boolean] :cache_session_requests Whether this client's
682
+ # response cache stores and serves reads made with a session token.
683
+ # Overrides {Parse::Middleware::Caching.cache_session_requests} in
684
+ # either direction; omit it to use that class default (off). Only
685
+ # `true` enables it. With it on, a revoked session keeps reading its
686
+ # cached rows until the entry expires.
648
687
  # @option opts [String] :cache_namespace Optional prefix applied to every
649
688
  # cache key. Useful when two Parse apps share one Redis instance and
650
689
  # would otherwise collide on identical paths (e.g.
@@ -704,7 +743,9 @@ module Parse
704
743
  @allow_faraday_proxy = opts.fetch(:allow_faraday_proxy, false)
705
744
 
706
745
  # Security check for HTTP usage (except localhost/127.0.0.1 for development)
707
- if @server_url&.start_with?("http://") && !@server_url.match?(%r{^http://(localhost|127\.0\.0\.1)(:|/)})
746
+ # The scheme and host come from URI parsing, so `HTTP://` and leading
747
+ # whitespace are treated as plain http, not as a secure URL.
748
+ if self.class.url_scheme(@server_url) == "http" && !self.class.loopback_host?(self.class.url_host(@server_url))
708
749
  if @require_https
709
750
  raise ArgumentError, "[Parse::Client] HTTPS required but server URL uses HTTP: #{@server_url}. " \
710
751
  "Set require_https: false or use an HTTPS URL."
@@ -765,7 +806,7 @@ module Parse
765
806
  # scheme; without this guard a caller passing
766
807
  # faraday: { ssl: { verify: false }, proxy: "http://attacker" }
767
808
  # would neuter TLS verification on an HTTPS connection.
768
- validate_faraday_opts!(opts[:faraday])
809
+ opts[:faraday] = validate_faraday_opts!(opts[:faraday])
769
810
  opts[:faraday].merge!(:url => @server_url)
770
811
  @conn = Faraday.new(opts[:faraday]) do |conn|
771
812
  # Apply timeouts before any user-supplied middleware sees a request.
@@ -937,6 +978,10 @@ module Parse
937
978
  # old workers still read the legacy shape, so invalidation has to
938
979
  # hit both until every old worker is drained.
939
980
  delete_legacy_variants: opts.fetch(:cache_delete_legacy_variants, true),
981
+ # Per-client override of
982
+ # Parse::Middleware::Caching.cache_session_requests; omitted
983
+ # means the class default applies.
984
+ **(opts.key?(:cache_session_requests) ? { cache_session_requests: opts[:cache_session_requests] } : {}),
940
985
  }
941
986
 
942
987
  # Inform about opt-in cache behavior
@@ -986,19 +1031,27 @@ module Parse
986
1031
  # controlled MITM unless explicitly allowlisted
987
1032
  #
988
1033
  # @api private
1034
+ # @return [Hash] the options as a Hash, so a `Faraday::ConnectionOptions`
1035
+ # (or anything else responding to `to_hash`) is checked and used in the
1036
+ # same form; any other value raises.
989
1037
  def validate_faraday_opts!(faraday_opts)
990
- return unless faraday_opts.is_a?(Hash)
991
-
992
- ssl = faraday_opts[:ssl] || faraday_opts["ssl"]
993
- if ssl.is_a?(Hash)
994
- verify = ssl.key?(:verify) ? ssl[:verify] : ssl["verify"]
995
- if verify == false && @server_url.to_s.start_with?("https://")
1038
+ faraday_opts = {} if faraday_opts.nil?
1039
+ unless faraday_opts.is_a?(Hash)
1040
+ unless faraday_opts.respond_to?(:to_hash)
996
1041
  raise ArgumentError,
997
- "[Parse::Client] Refusing to disable TLS certificate verification " \
998
- "(opts[:faraday][:ssl][:verify] = false) on an HTTPS server URL. " \
999
- "Fix the server certificate or downgrade the URL to http:// " \
1000
- "(with require_https: false) for explicit local testing."
1042
+ "[Parse::Client] opts[:faraday] must be a Hash or Faraday::ConnectionOptions " \
1043
+ "(got #{faraday_opts.class})."
1001
1044
  end
1045
+ faraday_opts = faraday_opts.to_hash
1046
+ end
1047
+
1048
+ ssl = faraday_opts[:ssl] || faraday_opts["ssl"]
1049
+ if self.class.url_scheme(@server_url) == "https" && (setting = tls_verification_disabled(ssl))
1050
+ raise ArgumentError,
1051
+ "[Parse::Client] Refusing to disable TLS certificate verification " \
1052
+ "(opts[:faraday][:ssl] #{setting}) on an HTTPS server URL. " \
1053
+ "Fix the server certificate or downgrade the URL to http:// " \
1054
+ "(with require_https: false) for explicit local testing."
1002
1055
  end
1003
1056
 
1004
1057
  proxy = faraday_opts[:proxy] || faraday_opts["proxy"]
@@ -1018,15 +1071,82 @@ module Parse
1018
1071
  # `proxy: nil` is the Faraday-documented way to disable
1019
1072
  # env-proxy autodiscovery.
1020
1073
  faraday_opts[:proxy] = nil unless @allow_faraday_proxy
1074
+ faraday_opts
1021
1075
  end
1022
1076
 
1023
1077
  private :validate_faraday_opts!
1024
1078
 
1025
- # Hosts considered "loopback" for the cleartext-ws:// guard in
1026
- # {#configure_live_query}. Mirrors
1027
- # {Parse::LiveQuery::Client::LOOPBACK_HOSTS} so the explicit-URL
1028
- # path and the derived-URL path agree on what counts as local.
1029
- LIVE_QUERY_LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1] 0.0.0.0].freeze
1079
+ # Which TLS setting in a Faraday `ssl:` option (a Hash or a
1080
+ # `Faraday::SSLOptions`) turns certificate or hostname verification off,
1081
+ # or nil when none does: `verify: false`, `verify_mode: VERIFY_NONE`, or
1082
+ # `verify_hostname: false`.
1083
+ # @api private
1084
+ def tls_verification_disabled(ssl)
1085
+ read = lambda do |key|
1086
+ if ssl.is_a?(Hash)
1087
+ ssl.key?(key) ? ssl[key] : ssl[key.to_s]
1088
+ elsif ssl.respond_to?(key)
1089
+ ssl.public_send(key)
1090
+ end
1091
+ end
1092
+ return nil if ssl.nil?
1093
+ return "verify: false" if read.call(:verify) == false
1094
+ if defined?(OpenSSL::SSL::VERIFY_NONE) && read.call(:verify_mode) == OpenSSL::SSL::VERIFY_NONE
1095
+ return "verify_mode: VERIFY_NONE"
1096
+ end
1097
+ return "verify_hostname: false" if read.call(:verify_hostname) == false
1098
+ nil
1099
+ end
1100
+ private :tls_verification_disabled
1101
+
1102
+ # Whether a URL host is this machine: `localhost`, any `127.0.0.0/8`
1103
+ # address, `::1`, or `0.0.0.0` (a connect to the unspecified address
1104
+ # reaches the local host on Linux and macOS). Used by the http:// server
1105
+ # warning and the LiveQuery cleartext guards so they agree.
1106
+ # @param host [String, nil]
1107
+ # @return [Boolean]
1108
+ # @api private
1109
+ #
1110
+ # Addresses are parsed with IPAddr, so a malformed one such as
1111
+ # `127.999.1.1` (which a resolver would look up by name) is not loopback.
1112
+ # `localhost.` with a trailing dot is not either: some resolvers skip
1113
+ # `/etc/hosts` for the fully qualified form. `0.0.0.0` stays local for
1114
+ # client URLs only (it is a bind address everywhere else).
1115
+ def self.loopback_host?(host)
1116
+ h = host.to_s.strip.downcase.delete_prefix("[").delete_suffix("]")
1117
+ return false if h.empty?
1118
+ return true if h == "localhost" || h == "0.0.0.0"
1119
+ return false unless h.match?(/\A[0-9a-f:.]+\z/)
1120
+ require "ipaddr"
1121
+ IPAddr.new(h).loopback?
1122
+ rescue IPAddr::InvalidAddressError, IPAddr::AddressFamilyError
1123
+ false
1124
+ end
1125
+
1126
+ # The lowercased scheme of a URL String, or nil when it does not parse.
1127
+ # Leading and trailing whitespace is ignored. Scheme checks go through
1128
+ # this instead of a case-sensitive prefix match, which let `HTTP://` and
1129
+ # `WS://` past the plaintext guards.
1130
+ # @param url [String, nil]
1131
+ # @return [String, nil]
1132
+ # @api private
1133
+ def self.url_scheme(url)
1134
+ return nil if url.nil?
1135
+ URI.parse(url.to_s.strip).scheme&.downcase
1136
+ rescue URI::InvalidURIError
1137
+ nil
1138
+ end
1139
+
1140
+ # The lowercased host of a URL String, or nil when it does not parse.
1141
+ # @param url [String, nil]
1142
+ # @return [String, nil]
1143
+ # @api private
1144
+ def self.url_host(url)
1145
+ return nil if url.nil?
1146
+ URI.parse(url.to_s.strip).host&.downcase
1147
+ rescue URI::InvalidURIError
1148
+ nil
1149
+ end
1030
1150
 
1031
1151
  # Configure LiveQuery with the given options
1032
1152
  # @param opts [Hash] configuration options
@@ -1049,7 +1169,7 @@ module Parse
1049
1169
  # `live_query: { url: "ws://prod-host" }` or
1050
1170
  # `live_query_url: "ws://prod-host"` bypassed it — the master key
1051
1171
  # and any session token would ride the connect frame in cleartext.
1052
- validate_live_query_url!(resolved_url, allow_insecure: live_query_opts[:allow_insecure])
1172
+ resolved_url = validate_live_query_url!(resolved_url, allow_insecure: live_query_opts[:allow_insecure])
1053
1173
 
1054
1174
  # Warn (don't raise) on `live_query: { ... }` keys that are not
1055
1175
  # `Parse::LiveQuery::Configuration` setters. The block form would
@@ -1079,20 +1199,14 @@ module Parse
1079
1199
  end
1080
1200
  end
1081
1201
 
1202
+ # Validate an explicit LiveQuery URL at configure time with the same
1203
+ # rules {Parse::LiveQuery::Client} applies, and return the URL to use
1204
+ # (`http://` and `https://` are mapped to `ws://` and `wss://`).
1205
+ # @return [String, nil]
1082
1206
  # @api private
1083
1207
  def validate_live_query_url!(url, allow_insecure:)
1084
- return unless url.is_a?(String) && url.start_with?("ws://")
1085
-
1086
- host = URI.parse(url).host.to_s rescue ""
1087
- return if LIVE_QUERY_LOOPBACK_HOSTS.include?(host)
1088
- return if allow_insecure
1089
-
1090
- raise ArgumentError,
1091
- "[Parse::Client] Refusing explicit insecure LiveQuery URL #{url.inspect}. " \
1092
- "The connect frame carries the master key and any session token in " \
1093
- "plaintext on this socket. Use wss:// for routable hosts, or pass " \
1094
- "`live_query: { allow_insecure: true }` to opt into cleartext for " \
1095
- "local development on a non-loopback address."
1208
+ return url unless url.is_a?(String)
1209
+ Parse::LiveQuery::Client.normalize_url(url, allow_insecure: allow_insecure)
1096
1210
  end
1097
1211
 
1098
1212
  # @api private
@@ -1383,7 +1497,9 @@ module Parse
1383
1497
  # `with_session(token_b)` resolved user B, and the identity cache
1384
1498
  # then mapped token A (or a garbage token) to user B for its TTL.
1385
1499
  if raw_token.nil?
1386
- header_token = headers[Parse::Protocol::SESSION_TOKEN]
1500
+ # Same lookup a batch uses for each request's own credentials
1501
+ # (Parse::Request#explicit_authority).
1502
+ header_token = Parse::Request.header_value(headers, Parse::Protocol::SESSION_TOKEN)
1387
1503
  raw_token = header_token if header_token.is_a?(String)
1388
1504
  end
1389
1505
  # SEC-02:an EXPLICITLY-supplied session_token that is a blank /
@@ -158,8 +158,11 @@ module Parse
158
158
  use_master_key: NOT_PROVIDED, auto_connect: nil, auto_reconnect: nil)
159
159
  cfg = config
160
160
 
161
- # Use provided values or fall back to configuration/environment
162
- @url = url || cfg.url || derive_websocket_url
161
+ # Use provided values or fall back to configuration/environment. An
162
+ # explicit or configured URL is validated here; a derived one is
163
+ # validated by derive_websocket_url.
164
+ explicit_url = url || cfg.url
165
+ @url = explicit_url ? validate_websocket_url!(explicit_url) : derive_websocket_url
163
166
  @application_id = application_id || cfg.application_id ||
164
167
  parse_client_value(:application_id)
165
168
  @client_key = client_key || cfg.client_key ||
@@ -509,12 +512,70 @@ module Parse
509
512
  nil
510
513
  end
511
514
 
512
- # Loopback hostnames exempt from the `ws://` refusal in
513
- # {#derive_websocket_url}. These addresses can't reach the
514
- # Internet, so the cleartext-credentials threat model doesn't
515
- # apply — but we still emit a warning so the operator knows
516
- # they're on `ws://`.
517
- LOOPBACK_HOSTS = %w[localhost 127.0.0.1 ::1 [::1] 0.0.0.0].freeze
515
+ # Normalize and validate an explicit LiveQuery URL, returning the URL
516
+ # to connect to.
517
+ #
518
+ # - The scheme is compared case-insensitively and the URL is stripped.
519
+ # - `https://` is mapped to `wss://` and `http://` to `ws://`, with a
520
+ # one-time deprecation warning. The connector used to open them as a
521
+ # plaintext socket, so the `https` mapping is strictly safer.
522
+ # - Plaintext `ws://` (including a mapped `http://`) is refused on a
523
+ # host that is not this machine ({Parse::Client.loopback_host?})
524
+ # unless `allow_insecure` is set, in which case it warns.
525
+ # - Any other scheme raises `ArgumentError`.
526
+ #
527
+ # @param url [String]
528
+ # @param allow_insecure [Boolean]
529
+ # @return [String]
530
+ # @raise [ArgumentError]
531
+ def self.normalize_url(url, allow_insecure: false)
532
+ scheme = Parse::Client.url_scheme(url)
533
+ mapped = { "http" => "ws", "https" => "wss" }[scheme]
534
+ if mapped
535
+ warn_http_scheme_once(scheme, mapped)
536
+ scheme = mapped
537
+ end
538
+ unless %w[ws wss].include?(scheme)
539
+ raise ArgumentError,
540
+ "[Parse::LiveQuery] LiveQuery URL #{url.to_s.inspect} must use wss:// " \
541
+ "(or ws:// on a loopback host). Got scheme #{scheme.inspect}."
542
+ end
543
+ normalized = url.to_s.strip.sub(/\A[a-z][a-z0-9+.\-]*:/i, "#{scheme}:")
544
+ host = Parse::Client.url_host(normalized).to_s
545
+ return normalized if scheme == "wss" || Parse::Client.loopback_host?(host)
546
+
547
+ if allow_insecure
548
+ warn "[Parse::LiveQuery] Using insecure ws:// URL for #{host} " \
549
+ "(allow_insecure is enabled). Master key and session tokens " \
550
+ "will traverse a cleartext socket."
551
+ normalized
552
+ else
553
+ raise ArgumentError,
554
+ "[Parse::LiveQuery] Refusing insecure ws:// LiveQuery URL #{url.to_s.inspect}. " \
555
+ "The connect frame carries the master key and any session token in " \
556
+ "plaintext on this socket. Use wss:// for routable hosts, or set " \
557
+ "`Parse::LiveQuery.configure { |c| c.allow_insecure = true }` (or " \
558
+ "`live_query: { allow_insecure: true }`) for local development."
559
+ end
560
+ end
561
+
562
+ # @!visibility private
563
+ def self.warn_http_scheme_once(scheme, mapped)
564
+ @warned_http_schemes ||= {}
565
+ return if @warned_http_schemes[scheme]
566
+ @warned_http_schemes[scheme] = true
567
+ warn "[Parse::LiveQuery] DEPRECATION: an #{scheme}:// LiveQuery URL is " \
568
+ "treated as #{mapped}://. Configure the #{mapped}:// URL directly; " \
569
+ "a later release will refuse #{scheme}://."
570
+ end
571
+
572
+ # Validate an explicit or configured LiveQuery URL the same way a
573
+ # derived one is, and return the URL to connect to. See
574
+ # {Parse::LiveQuery::Client.normalize_url}.
575
+ # @return [String]
576
+ def validate_websocket_url!(url)
577
+ self.class.normalize_url(url, allow_insecure: config.allow_insecure)
578
+ end
518
579
 
519
580
  # Derive WebSocket URL from Parse server URL. Refuses to
520
581
  # synthesize a `ws://` URL from an `http://` server URL on any
@@ -530,7 +591,7 @@ module Parse
530
591
  scheme = uri.scheme == "https" ? "wss" : "ws"
531
592
  host = uri.host.to_s
532
593
 
533
- if scheme == "ws" && !LOOPBACK_HOSTS.include?(host)
594
+ if scheme == "ws" && !Parse::Client.loopback_host?(host)
534
595
  if config.allow_insecure
535
596
  warn "[Parse::LiveQuery] Deriving insecure ws:// URL for #{host} " \
536
597
  "(allow_insecure is enabled). Master key and session tokens " \
@@ -251,12 +251,15 @@ module Parse
251
251
  # already revoked elsewhere or one the caller cannot see; dropping
252
252
  # cached entries is idempotent in both cases. A raised delete still
253
253
  # drops the token it named but leaves the owner alone. A single
254
- # delete cannot tell "already gone" from "denied", so an absent row
255
- # only uses the recorded owner, never the reset fallback.
254
+ # delete that returns false cannot tell "already gone" from
255
+ # "denied", so it never resets and forgets nothing for a row the
256
+ # caller could not read; one that returns true removed the row, so
257
+ # the recorded owner (or the rate-limited reset) applies.
256
258
  if result.nil?
257
259
  _forget_identity!(token.is_a?(String) ? token : nil, nil)
258
260
  else
259
- _forget_identity!(token, owner_id, reset_fallback: false)
261
+ deleted = result == true
262
+ _forget_identity!(token, owner_id, reset_fallback: deleted, deleted: deleted)
260
263
  end
261
264
  end
262
265
  end
@@ -312,16 +315,23 @@ module Parse
312
315
 
313
316
  # Look up the token and owner of every session about to be deleted that
314
317
  # does not carry them, so the delete can drop their identity entries.
315
- # One `_Session` query per client. A client with a master key reads it
316
- # as SDK metadata (it works inside `Parse.without_master_key`); one
317
- # without reads it with `session_token`, or skips the lookup when there
318
- # is none. The query never uses the response cache: its rows carry live
319
- # session tokens.
318
+ # One `_Session` query per client. When the delete runs as a user
319
+ # (`session_token`), the lookup runs as that user too, so it only
320
+ # reads sessions that user can see. Otherwise a client with a master
321
+ # key reads it as SDK metadata (it works inside
322
+ # `Parse.without_master_key`), and one without skips the lookup. The
323
+ # query never uses the response cache: its rows carry live session
324
+ # tokens.
320
325
  #
321
326
  # A row the lookup did not return is marked absent: there is nothing
322
- # to forget for it. Only a lookup that raised marks its sessions
323
- # `:unknown`, which makes their delete reset the client's identity
324
- # cache (rate limited).
327
+ # to forget for it. A row a session-scoped lookup could not see is
328
+ # marked `:invisible`: a denied or not-found delete of it forgets
329
+ # nothing and never resets, so a user deleting session ids they cannot
330
+ # read cannot evict other users' cached identities. A delete of it
331
+ # that succeeds removed a session the caller was allowed to delete, so
332
+ # its recorded owner (or the rate-limited reset) is used. Only
333
+ # a lookup that raised marks its sessions `:unknown`, which makes
334
+ # their delete reset the client's identity cache (rate limited).
325
335
  # @param sessions [Array<Parse::Object>]
326
336
  # @param session_token [String, nil] the delete's own session.
327
337
  # @!visibility private
@@ -337,15 +347,16 @@ module Parse
337
347
  next
338
348
  end
339
349
  ids = group.map(&:id).uniq
350
+ scoped = !token.nil?
340
351
  found = begin
341
352
  query = Parse::Session.query(:objectId.in => ids, limit: ids.size)
342
353
  query.keys(:session_token, :user)
343
354
  query.client = cl
344
355
  query.cache = false
345
- if has_master
346
- query.instance_variable_set(:@_metadata_master, true)
347
- else
356
+ if scoped
348
357
  query.session_token = token
358
+ else
359
+ query.instance_variable_set(:@_metadata_master, true)
349
360
  end
350
361
  query.results.to_h do |row|
351
362
  owner = row.instance_variable_get(:@user)
@@ -362,6 +373,8 @@ module Parse
362
373
  :unknown
363
374
  elsif (row = found[o.id]) && (row[0].is_a?(String) || row[1])
364
375
  row
376
+ elsif scoped
377
+ :invisible
365
378
  else
366
379
  :absent
367
380
  end
@@ -379,10 +392,14 @@ module Parse
379
392
  identity = _identity_for_destroy
380
393
  _clear_identity_for_destroy!
381
394
  return unless Parse::Session.send(:_destroy_applied?, response)
395
+ deleted = response.nil? || (response.respond_to?(:success?) && response.success?)
396
+ # A row the caller could not read counts only when the delete itself
397
+ # succeeded: "object not found" there may mean "not yours".
398
+ return if identity[0] == :invisible && !deleted
382
399
  # The batch response tells success and "object not found" apart from
383
400
  # a denial, so an absent row whose owner was never recorded may fall
384
401
  # back to the rate-limited reset here.
385
- _forget_identity!(*identity, reset_fallback: true)
402
+ _forget_identity!(*identity, reset_fallback: true, deleted: deleted)
386
403
  end
387
404
  private :_after_batch_destroy
388
405
 
@@ -451,6 +468,10 @@ module Parse
451
468
  token = @session_token.is_a?(String) && !@session_token.empty? ? @session_token : nil
452
469
  owner_id = _identity_owner_id
453
470
  return [:unknown, owner_id] if looked_up == :unknown
471
+ # A session-scoped lookup could not see the row: forget what this
472
+ # instance carries; with nothing at all, mark it invisible so only a
473
+ # delete that succeeds may use the recorded owner or the reset.
474
+ return (token || owner_id ? [token, owner_id] : [:invisible, nil]) if looked_up == :invisible
454
475
  if looked_up == :absent
455
476
  # No row to read: the session is gone or not visible. Forget what
456
477
  # this instance carries; with nothing at all, mark it absent so the
@@ -476,8 +497,15 @@ module Parse
476
497
  # Drop the token and the owner's entries from this session's client's
477
498
  # identity plane.
478
499
  # @!visibility private
479
- def _forget_identity!(token, owner_id, reset_fallback: false)
500
+ def _forget_identity!(token, owner_id, reset_fallback: false, deleted: false)
480
501
  cl = client
502
+ if token == :invisible
503
+ # The caller could not read the row. Only a delete that succeeded
504
+ # touches other identities: the recorded owner, else the
505
+ # rate-limited reset. A denied or not-found delete forgets nothing.
506
+ return unless deleted
507
+ token = :absent
508
+ end
481
509
  if token == :absent
482
510
  # The row was not readable before the delete. Use the owner recorded
483
511
  # when this session was last loaded; without one, and only when the
@@ -432,11 +432,20 @@ module Parse
432
432
  # default; enable it only for writes that are safe to repeat (it
433
433
  # works around Parse Server running a transaction's requests
434
434
  # concurrently, which MongoDB intermittently rejects).
435
+ # @param session [String, #session_token, nil] run the transaction as
436
+ # this user (ACL and CLP enforced). Without it, the transaction runs
437
+ # with the credentials of its objects' class client, as a single
438
+ # save does.
435
439
  # @yield [Parse::BatchOperation] the batch operation to add requests to
436
440
  # @return [Array<Parse::Response>] the responses from the transaction
437
441
  # @raise [Parse::Error] if the transaction fails
438
- def transaction(retries: 5, retry_server_errors: false, &block)
442
+ # @raise [Parse::BatchOperation::MixedAuthorityError] if the objects
443
+ # are bound to clients with different credentials. Parse Server runs
444
+ # a transaction under one credential, so it is refused before
445
+ # anything is sent.
446
+ def transaction(retries: 5, retry_server_errors: false, session: nil, &block)
439
447
  raise ArgumentError, "Block required for transaction" unless block_given?
448
+ session_token = Parse::BatchOperation.session_token_for!(session)
440
449
 
441
450
  previous_context = Fiber[TRANSACTION_CONTEXT_KEY]
442
451
  transaction_context = { snapshots: {}, created_objects: {} }
@@ -490,6 +499,10 @@ module Parse
490
499
  result.each { |obj| batch_wrapper.add(obj) if obj.respond_to?(:change_requests) }
491
500
  end
492
501
 
502
+ if session_token
503
+ batch.requests.each { |r| r.opts[:session_token] = session_token if r.opts.is_a?(Hash) }
504
+ end
505
+
493
506
  # Submit with retry logic for transaction conflicts.
494
507
  # Parse Server reports a write conflict inside a transaction as
495
508
  # error code 251, after rolling it back. Retry on that structured
@@ -1266,6 +1279,9 @@ module Parse
1266
1279
  uri = self.uri_path
1267
1280
  r = Request.new(:delete, uri)
1268
1281
  r.tag = object_id
1282
+ # A batch sends this through the class's client (and its bound
1283
+ # session), as a single destroy does.
1284
+ r.client = client
1269
1285
  r
1270
1286
  end
1271
1287
 
@@ -1276,6 +1292,8 @@ module Parse
1276
1292
 
1277
1293
  # Creates an array of all possible operations that need to be performed
1278
1294
  # on this object. This includes all property and relational operation changes.
1295
+ # Each request carries this class's client, so a batch sends it with the
1296
+ # same credentials as a single {#save}.
1279
1297
  #
1280
1298
  # This is the path batch saves ({Array#save}) and
1281
1299
  # {Parse::Object.transaction} use, so it applies the same save-time
@@ -1309,6 +1327,7 @@ module Parse
1309
1327
  body[Parse::Model::OBJECT_ID] = @id if @id.present?
1310
1328
  r = Request.new(:post, uri, body: body)
1311
1329
  r.tag = object_id
1330
+ r.client = client
1312
1331
  requests << r
1313
1332
  end
1314
1333
  return requests
@@ -1318,6 +1337,7 @@ module Parse
1318
1337
  if attribute_changes? || force
1319
1338
  r = Request.new(:put, uri, body: attribute_updates)
1320
1339
  r.tag = object_id
1340
+ r.client = client
1321
1341
  requests << r
1322
1342
  end
1323
1343
 
@@ -1328,6 +1348,7 @@ module Parse
1328
1348
  next if ops.empty?
1329
1349
  r = Request.new(:put, uri, body: ops)
1330
1350
  r.tag = object_id
1351
+ r.client = client
1331
1352
  requests << r
1332
1353
  end
1333
1354
  end