parse-stack-next 5.8.0 → 5.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +630 -0
- data/README.md +31 -19
- data/docs/caching.md +90 -5
- data/docs/mcp_guide.md +22 -5
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +94 -9
- data/lib/parse/acl_scope.rb +71 -9
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/describe.rb +8 -2
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +521 -70
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search.rb +50 -1
- data/lib/parse/authorization.rb +235 -3
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +263 -11
- 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 +173 -33
- data/lib/parse/clp_scope.rb +37 -3
- data/lib/parse/live_query/client.rb +142 -11
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/associations/collection_proxy.rb +19 -11
- data/lib/parse/model/associations/has_many.rb +11 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
- data/lib/parse/model/classes/role.rb +23 -0
- data/lib/parse/model/classes/session.rb +307 -19
- data/lib/parse/model/classes/user.rb +33 -12
- data/lib/parse/model/core/actions.rb +41 -5
- data/lib/parse/model/core/fetching.rb +29 -2
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/model/object.rb +52 -1
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +43 -2
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraint.rb +1 -1
- data/lib/parse/query/constraints.rb +665 -41
- data/lib/parse/query.rb +802 -60
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +23 -0
- data/lib/parse/vector_search/hybrid.rb +78 -16
- data/lib/parse/vector_search.rb +215 -7
- data/lib/parse/webhooks/payload.rb +82 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +329 -13
- 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)
|
|
@@ -266,33 +456,61 @@ class Array
|
|
|
266
456
|
# Each object whose delete succeeds has its local state updated the same
|
|
267
457
|
# way {Parse::Object#destroy} updates it. Objects whose delete fails are
|
|
268
458
|
# left untouched; inspect the returned batch's responses to find them.
|
|
269
|
-
# Destroy callbacks are not run.
|
|
459
|
+
# Destroy callbacks are not run. Every {Parse::Session} and {Parse::User}
|
|
460
|
+
# whose delete succeeded, or reported "object not found" (the row is
|
|
461
|
+
# already gone, for example revoked elsewhere), is dropped from its
|
|
462
|
+
# client's identity plane, as their single-object destroy does.
|
|
270
463
|
# @example
|
|
271
464
|
# # assume Post and Author are Parse models
|
|
272
465
|
# author = Author.first
|
|
273
466
|
# posts = Post.all author: author
|
|
274
467
|
# posts.destroy # batch destroy request
|
|
468
|
+
# @param session [String, #session_token, nil] send every delete as this
|
|
469
|
+
# user (ACL and CLP enforced) instead of each object's client default.
|
|
275
470
|
# @return [Parse::BatchOperation] the batch operation performed.
|
|
276
471
|
# @raise ArgumentError if the array is not empty and holds no Parse objects.
|
|
472
|
+
# @raise Parse::BatchOperation::MixedAuthorityError never for a destroy;
|
|
473
|
+
# objects bound to different clients are deleted in separate calls.
|
|
277
474
|
# @see Parse::BatchOperation
|
|
278
|
-
def destroy
|
|
475
|
+
def destroy(session: nil)
|
|
476
|
+
token = Parse::BatchOperation.session_token_for!(session)
|
|
279
477
|
targets = select { |o| o.respond_to?(:destroy_request) }
|
|
280
478
|
if targets.empty? && !empty?
|
|
281
479
|
raise ArgumentError, "Array#destroy requires Parse::Object elements; " \
|
|
282
480
|
"this array holds none (#{first.class})"
|
|
283
481
|
end
|
|
482
|
+
# A session deleted from a pointer or a partial fetch carries no token or
|
|
483
|
+
# owner to drop from the identity cache; look them up first, in one
|
|
484
|
+
# query per client.
|
|
485
|
+
Parse::Session.send(:_preload_identity_for_destroy!, targets, session_token: token) if defined?(Parse::Session)
|
|
486
|
+
_destroy_batch(targets, token)
|
|
487
|
+
ensure
|
|
488
|
+
# A looked-up session token is a live credential; never leave it on an
|
|
489
|
+
# object whose delete was skipped or whose batch raised.
|
|
490
|
+
targets&.each { |o| o.send(:_clear_identity_for_destroy!) if _batch_identity_hook?(o, :_clear_identity_for_destroy!) }
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# @!visibility private
|
|
494
|
+
def _destroy_batch(targets, token = nil)
|
|
284
495
|
batch = Parse::BatchOperation.new
|
|
285
496
|
objects = {}
|
|
286
497
|
targets.each do |o|
|
|
287
498
|
next if objects.key?(o.object_id)
|
|
288
499
|
r = o.destroy_request
|
|
289
500
|
next if r.nil?
|
|
501
|
+
r.opts[:session_token] = token if token
|
|
290
502
|
objects[o.object_id] = o
|
|
291
503
|
batch.add(r)
|
|
292
504
|
end
|
|
293
505
|
batch.submit do |request, response|
|
|
294
506
|
o = objects[request.tag]
|
|
295
|
-
next unless o
|
|
507
|
+
next unless o
|
|
508
|
+
# Sessions and users drop their identity-plane entries when the delete
|
|
509
|
+
# applied or the row is already gone ("object not found"), so a revoked
|
|
510
|
+
# token stops resolving now. They decide from the response; a denied
|
|
511
|
+
# delete leaves the cache alone.
|
|
512
|
+
o.send(:_after_batch_destroy, response) if _batch_identity_hook?(o, :_after_batch_destroy)
|
|
513
|
+
next unless response.respond_to?(:success?) && response.success?
|
|
296
514
|
# Mirror Parse::Object#destroy: keep the id and mark the object
|
|
297
515
|
# destroyed so it reports `destroyed?` and a later save refuses.
|
|
298
516
|
o.instance_variable_set(:@_destroyed, true)
|
|
@@ -300,6 +518,17 @@ class Array
|
|
|
300
518
|
end
|
|
301
519
|
batch
|
|
302
520
|
end
|
|
521
|
+
private :_destroy_batch
|
|
522
|
+
|
|
523
|
+
# Whether `o`'s class defines the internal (non-public) identity hook
|
|
524
|
+
# `name`. Checked on the class rather than with `respond_to?(name, true)`
|
|
525
|
+
# so a duck-typed element overriding `respond_to?` cannot break the batch.
|
|
526
|
+
# @!visibility private
|
|
527
|
+
def _batch_identity_hook?(o, name)
|
|
528
|
+
klass = o.class
|
|
529
|
+
klass.private_method_defined?(name) || klass.method_defined?(name)
|
|
530
|
+
end
|
|
531
|
+
private :_batch_identity_hook?
|
|
303
532
|
|
|
304
533
|
# Do not alias method as :delete is already part of array.
|
|
305
534
|
# alias_method :delete, :destroy
|
|
@@ -323,16 +552,22 @@ class Array
|
|
|
323
552
|
# objects back to the original ones submitted. If you don't need the original objects
|
|
324
553
|
# to be updated with the changes, set this to false for improved performance.
|
|
325
554
|
# @param force [Boolean] Do not skip objects that do not have pending changes (dirty tracking).
|
|
555
|
+
# @param session [String, #session_token, nil] send every write as this
|
|
556
|
+
# user (ACL and CLP enforced) instead of each object's client default.
|
|
326
557
|
# @example
|
|
327
558
|
# # assume Post and Author are Parse models
|
|
328
559
|
# author = Author.first
|
|
329
560
|
# posts = Post.first 100
|
|
330
561
|
# posts.each { |post| post.author = author }
|
|
331
562
|
# posts.save # batch save
|
|
563
|
+
# @note Each write is sent with the credentials of its object's class
|
|
564
|
+
# client, as a single {Parse::Object#save} is. Objects bound to different
|
|
565
|
+
# clients are saved in separate batch calls.
|
|
332
566
|
# @return [Parse::BatchOperation] the batch operation performed.
|
|
333
567
|
# @raise ArgumentError if the array is not empty and holds no Parse objects.
|
|
334
568
|
# @see Parse::BatchOperation
|
|
335
|
-
def save(merge: true, force: false)
|
|
569
|
+
def save(merge: true, force: false, session: nil)
|
|
570
|
+
token = Parse::BatchOperation.session_token_for!(session)
|
|
336
571
|
targets = select { |o| o.is_a?(Parse::Object) }
|
|
337
572
|
if targets.empty? && !empty?
|
|
338
573
|
raise ArgumentError, "Array#save requires Parse::Object elements; " \
|
|
@@ -343,7 +578,9 @@ class Array
|
|
|
343
578
|
targets.each do |o|
|
|
344
579
|
next if objects.key?(o.object_id)
|
|
345
580
|
objects[o.object_id] = o
|
|
346
|
-
|
|
581
|
+
reqs = o.change_requests(force)
|
|
582
|
+
reqs.each { |r| r.opts[:session_token] = token } if token
|
|
583
|
+
batch.add reqs
|
|
347
584
|
end
|
|
348
585
|
if merge == false
|
|
349
586
|
batch.submit
|
|
@@ -367,6 +604,21 @@ end
|
|
|
367
604
|
|
|
368
605
|
module Parse
|
|
369
606
|
class BatchOperation
|
|
607
|
+
# @!visibility private
|
|
608
|
+
# The session token named by a `session:` argument, or nil when none was
|
|
609
|
+
# given. A blank token is refused rather than treated as "no session".
|
|
610
|
+
# @param session [String, #session_token, nil]
|
|
611
|
+
# @return [String, nil]
|
|
612
|
+
# @raise ArgumentError for a blank or unusable session.
|
|
613
|
+
def self.session_token_for!(session)
|
|
614
|
+
return nil if session.nil?
|
|
615
|
+
token = session.respond_to?(:session_token) ? session.session_token : session
|
|
616
|
+
unless token.is_a?(String) && !token.strip.empty?
|
|
617
|
+
raise ArgumentError, "session: must be a session token or a user with one"
|
|
618
|
+
end
|
|
619
|
+
token
|
|
620
|
+
end
|
|
621
|
+
|
|
370
622
|
# @!visibility private
|
|
371
623
|
# Apply the batch responses for one object's requests to that object.
|
|
372
624
|
# @param obj [Parse::Object] the object that was saved.
|
|
@@ -36,12 +36,41 @@ module Parse
|
|
|
36
36
|
# Maximum url length for most server requests before HTTP Method Override is used.
|
|
37
37
|
MAX_URL_LENGTH = 2_000.freeze
|
|
38
38
|
# Fields that should be redacted from log output.
|
|
39
|
+
#
|
|
40
|
+
# Covers MFA material (`recovery` codes and the `secret` returned by
|
|
41
|
+
# MFA enrollment, also under `authDataResponse`) and the storage-form
|
|
42
|
+
# credential columns a raw MongoDB document carries (`_session_token`,
|
|
43
|
+
# `_hashed_password`, and the reset/verify tokens). Keys starting with
|
|
44
|
+
# {SENSITIVE_KEY_PREFIXES} (per-provider `_auth_data_<provider>`
|
|
45
|
+
# columns) are redacted too.
|
|
39
46
|
SENSITIVE_FIELDS = %w[
|
|
40
47
|
password token sessionToken session_token access_token authData
|
|
41
48
|
masterKey master_key apiKey api_key clientKey client_key
|
|
42
49
|
javascriptKey javascript_key refreshToken refresh_token
|
|
50
|
+
recovery recoveryCodes recovery_codes authDataResponse secret
|
|
51
|
+
_session_token _hashed_password _email_verify_token
|
|
52
|
+
_perishable_token _password_history
|
|
53
|
+
].freeze
|
|
54
|
+
# Key prefixes whose values are always redacted.
|
|
55
|
+
SENSITIVE_KEY_PREFIXES = %w[_auth_data_].freeze
|
|
56
|
+
# A credential value: a double- or single-quoted string (with escapes),
|
|
57
|
+
# taken whole so a quoted value with spaces is redacted entirely, or an
|
|
58
|
+
# unquoted run up to a delimiter.
|
|
59
|
+
SENSITIVE_VALUE = /(?:"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[^"'&\s,}\]]+)/
|
|
60
|
+
SENSITIVE_PATTERN = /(#{(SENSITIVE_FIELDS + SENSITIVE_KEY_PREFIXES.map { |p| "#{p}\\w+" }).join("|")})(["']?\s*[=:>]\s*)(#{SENSITIVE_VALUE.source})/i
|
|
61
|
+
# Credential header names whose value is redacted when they appear as
|
|
62
|
+
# text (`X-Parse-Master-Key: mk`, `Authorization: Bearer x`), for
|
|
63
|
+
# example in an error message or a pasted request. Real request
|
|
64
|
+
# headers are redacted through {REDACTED_HEADERS}.
|
|
65
|
+
SENSITIVE_HEADER_NAMES = %w[
|
|
66
|
+
X-Parse-Master-Key X-Parse-REST-API-Key X-Parse-Session-Token
|
|
67
|
+
X-Parse-Javascript-Key X-Parse-Client-Key X-Parse-Webhook-Key
|
|
68
|
+
Authorization
|
|
43
69
|
].freeze
|
|
44
|
-
|
|
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
|