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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +312 -0
- data/README.md +6 -0
- data/docs/caching.md +40 -5
- data/docs/mcp_guide.md +7 -2
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +35 -2
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +316 -50
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search.rb +1 -0
- data/lib/parse/client/batch.rb +231 -12
- data/lib/parse/client/body_builder.rb +100 -15
- data/lib/parse/client/caching.rb +53 -3
- data/lib/parse/client/request.rb +59 -0
- data/lib/parse/client/response.rb +63 -5
- data/lib/parse/client.rb +147 -31
- data/lib/parse/live_query/client.rb +70 -9
- data/lib/parse/model/classes/session.rb +44 -16
- data/lib/parse/model/core/actions.rb +22 -1
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/mongodb.rb +24 -1
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraints.rb +634 -14
- data/lib/parse/query.rb +9 -5
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +6 -2
- data/lib/parse/vector_search/hybrid.rb +4 -1
- data/lib/parse/vector_search.rb +1 -0
- data/lib/parse/webhooks/payload.rb +39 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +17 -4
- metadata +1 -1
data/lib/parse/client/batch.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
300
|
+
[entries, Array.new(reqs.size) { exception_response(e) }, e]
|
|
201
301
|
end
|
|
202
302
|
end
|
|
203
|
-
@responses =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
180
|
+
# Fall through to the text pass on the original string.
|
|
150
181
|
end
|
|
151
182
|
end
|
|
152
|
-
|
|
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
|
-
#
|
|
158
|
-
#
|
|
159
|
-
#
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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) &&
|
|
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)
|
data/lib/parse/client/caching.rb
CHANGED
|
@@ -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:,
|
|
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
|
data/lib/parse/client/request.rb
CHANGED
|
@@ -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
|