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
|
@@ -84,12 +84,34 @@ module Parse
|
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
|
|
87
|
+
# Check that a webhook endpoint is an http(s) URL (scheme compared
|
|
88
|
+
# case-insensitively, surrounding whitespace ignored) and return it
|
|
89
|
+
# stripped. Plain http:// to a host that is not this machine is
|
|
90
|
+
# allowed but warns: Parse Server sends the webhook key on every call,
|
|
91
|
+
# so it would travel in cleartext.
|
|
92
|
+
# @param endpoint [String]
|
|
93
|
+
# @return [String]
|
|
94
|
+
# @raise [ArgumentError]
|
|
95
|
+
# @!visibility private
|
|
96
|
+
def validate_hooks_endpoint!(endpoint)
|
|
97
|
+
url = endpoint.to_s.strip
|
|
98
|
+
scheme = Parse::Client.url_scheme(url)
|
|
99
|
+
host = Parse::Client.url_host(url)
|
|
100
|
+
unless %w[http https].include?(scheme) && host.present? && url.match?(%r{\Ahttps?://}i)
|
|
101
|
+
raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}'"
|
|
102
|
+
end
|
|
103
|
+
if scheme == "http" && !Parse::Client.loopback_host?(host)
|
|
104
|
+
warn "[Parse::Webhooks] SECURITY WARNING: webhook endpoint #{url} uses http://. " \
|
|
105
|
+
"Parse Server sends the webhook key with every call, so it travels in " \
|
|
106
|
+
"cleartext. Use an https:// endpoint."
|
|
107
|
+
end
|
|
108
|
+
url
|
|
109
|
+
end
|
|
110
|
+
|
|
87
111
|
# Registers all webhook functions registered with Parse::Stack with Parse server.
|
|
88
112
|
# @param endpoint [String] a https url that points to the webhook server.
|
|
89
113
|
def register_functions!(endpoint)
|
|
90
|
-
|
|
91
|
-
raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}''"
|
|
92
|
-
end
|
|
114
|
+
endpoint = validate_hooks_endpoint!(endpoint)
|
|
93
115
|
assert_webhook_url_safe!(endpoint)
|
|
94
116
|
endpoint += "/" unless endpoint.ends_with?("/")
|
|
95
117
|
functionsMap = {}
|
|
@@ -114,9 +136,7 @@ module Parse
|
|
|
114
136
|
# @param endpoint [String] a https url that points to the webhook server.
|
|
115
137
|
# @param include_wildcard [Boolean] Allow wildcard registrations
|
|
116
138
|
def register_triggers!(endpoint, include_wildcard: false)
|
|
117
|
-
|
|
118
|
-
raise ArgumentError, "The HOOKS_URL must be http/s: '#{endpoint}''"
|
|
119
|
-
end
|
|
139
|
+
endpoint = validate_hooks_endpoint!(endpoint)
|
|
120
140
|
assert_webhook_url_safe!(endpoint)
|
|
121
141
|
endpoint += "/" unless endpoint.ends_with?("/")
|
|
122
142
|
all_triggers = Parse::API::Hooks::TRIGGER_NAMES_LOCAL
|
|
@@ -124,16 +124,26 @@ module Parse
|
|
|
124
124
|
# works with any Rack request. Replay dedup applies only when
|
|
125
125
|
# `request_id` or an `X-Parse-Webhook-Nonce` header is present.
|
|
126
126
|
def verify!(env, body_str, request_id)
|
|
127
|
+
check(env, body_str, request_id).first
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# @!visibility private
|
|
131
|
+
# Same checks as {.verify!}, also reporting whether a signature was
|
|
132
|
+
# verified. The signing secret is read once, so the answer always
|
|
133
|
+
# matches the check that ran.
|
|
134
|
+
# @return [Array(String, Boolean)] the error message (nil when the
|
|
135
|
+
# request passes) and true when it carried a valid signature.
|
|
136
|
+
def check(env, body_str, request_id)
|
|
127
137
|
secret = signing_secret
|
|
128
138
|
signed_key = nil
|
|
129
139
|
if secret && !secret.empty?
|
|
130
140
|
ts_header = env[HEADER_TIMESTAMP].to_s
|
|
131
141
|
sig_header = env[HEADER_SIGNATURE].to_s
|
|
132
|
-
return "Missing webhook signature." if ts_header.empty? || sig_header.empty?
|
|
133
|
-
return "Invalid webhook timestamp." unless ts_header =~ /\A-?\d{1,12}\z/
|
|
142
|
+
return ["Missing webhook signature.", false] if ts_header.empty? || sig_header.empty?
|
|
143
|
+
return ["Invalid webhook timestamp.", false] unless ts_header =~ /\A-?\d{1,12}\z/
|
|
134
144
|
ts = ts_header.to_i
|
|
135
145
|
skew = (Time.now.to_i - ts).abs
|
|
136
|
-
return "Stale webhook timestamp." if skew > signing_max_skew_seconds
|
|
146
|
+
return ["Stale webhook timestamp.", false] if skew > signing_max_skew_seconds
|
|
137
147
|
# A sender that signs a per-delivery nonce (`ts.nonce.body`) gets a
|
|
138
148
|
# distinct signature for every delivery, so identical bodies sent
|
|
139
149
|
# in the same second are not mistaken for replays. The original
|
|
@@ -145,7 +155,7 @@ module Parse
|
|
|
145
155
|
expected = OpenSSL::HMAC.hexdigest("SHA256", secret, material)
|
|
146
156
|
ActiveSupport::SecurityUtils.secure_compare(expected, sig_header)
|
|
147
157
|
end
|
|
148
|
-
return "Invalid webhook signature." unless matched
|
|
158
|
+
return ["Invalid webhook signature.", false] unless matched
|
|
149
159
|
# A signed delivery is deduplicated on its signature, which covers
|
|
150
160
|
# the timestamp and body and cannot be changed without the secret.
|
|
151
161
|
# Keying it on the unsigned nonce would let a captured request be
|
|
@@ -157,23 +167,23 @@ module Parse
|
|
|
157
167
|
if signed_key
|
|
158
168
|
window = [replay_window_seconds, signing_max_skew_seconds * 2].max
|
|
159
169
|
digest = Digest::SHA256.hexdigest(signed_key)
|
|
160
|
-
return "Webhook replay detected." if cache.seen?(digest, window)
|
|
170
|
+
return ["Webhook replay detected.", false] if cache.seen?(digest, window)
|
|
161
171
|
cache.record(digest, replay_cache_size)
|
|
162
|
-
return nil
|
|
172
|
+
return [nil, true]
|
|
163
173
|
end
|
|
164
174
|
|
|
165
175
|
# Dedup only when the delivery carries a per-delivery identifier.
|
|
166
176
|
# Keying on the body alone rejects legitimate identical requests.
|
|
167
177
|
nonce = request_id.to_s.strip
|
|
168
178
|
nonce = env[HEADER_NONCE].to_s.strip if nonce.empty?
|
|
169
|
-
return nil if nonce.empty?
|
|
179
|
+
return [nil, false] if nonce.empty?
|
|
170
180
|
|
|
171
181
|
digest = Digest::SHA256.hexdigest("#{nonce}\x1f#{body_str}")
|
|
172
182
|
if cache.seen?(digest, replay_window_seconds)
|
|
173
|
-
return "Webhook replay detected."
|
|
183
|
+
return ["Webhook replay detected.", false]
|
|
174
184
|
end
|
|
175
185
|
cache.record(digest, replay_cache_size)
|
|
176
|
-
nil
|
|
186
|
+
[nil, false]
|
|
177
187
|
end
|
|
178
188
|
end
|
|
179
189
|
|
data/lib/parse/webhooks.rb
CHANGED
|
@@ -477,7 +477,10 @@ module Parse
|
|
|
477
477
|
payload&.raw&.dig("headers", "X-Parse-Request-Id")
|
|
478
478
|
ruby_initiated = request_id&.start_with?("_RB_") || false
|
|
479
479
|
payload.instance_variable_set(:@ruby_initiated, ruby_initiated)
|
|
480
|
-
|
|
480
|
+
# `master?` is the authenticated claim: on unauthenticated ingress
|
|
481
|
+
# it is false, so a forged `_RB_` id plus `"master": true` cannot
|
|
482
|
+
# suppress model callbacks.
|
|
483
|
+
trusted_ruby_initiated = ruby_initiated && payload.master?
|
|
481
484
|
else
|
|
482
485
|
trusted_ruby_initiated = false
|
|
483
486
|
end
|
|
@@ -501,6 +504,8 @@ module Parse
|
|
|
501
504
|
master: payload.master? || false,
|
|
502
505
|
is_new: payload.original.blank?,
|
|
503
506
|
)
|
|
507
|
+
# A guard revert is not the handler assigning the ACL.
|
|
508
|
+
pre_obj.reset_webhook_handler_acl_assigned! if pre_obj.respond_to?(:reset_webhook_handler_acl_assigned!)
|
|
504
509
|
end
|
|
505
510
|
end
|
|
506
511
|
end
|
|
@@ -529,6 +534,7 @@ module Parse
|
|
|
529
534
|
# ran ActiveModel before_save callbacks locally. A client-spoofed
|
|
530
535
|
# `_RB_` without master falls through and runs them here.
|
|
531
536
|
unless trusted_ruby_initiated
|
|
537
|
+
adopt_request_user_as_acl_owner!(payload, result)
|
|
532
538
|
before_save_result = result.run_before_save_callbacks
|
|
533
539
|
# If a before_save callback halted the chain (returned false), reject the save.
|
|
534
540
|
if before_save_result == false
|
|
@@ -670,7 +676,7 @@ module Parse
|
|
|
670
676
|
# matched route, so read that stamped value rather than recomputing via
|
|
671
677
|
# `ruby_initiated?` -- whose `||=` memoization re-derives on a stamped
|
|
672
678
|
# `false` and could disagree with call_route's header lookup.
|
|
673
|
-
return if payload.ruby_initiated? && payload.master?
|
|
679
|
+
return if payload.ruby_initiated? && payload.master?
|
|
674
680
|
|
|
675
681
|
# By the time afterSave fires the object is ALREADY persisted in Parse
|
|
676
682
|
# Server, and Parse Server discards the afterSave response body entirely
|
|
@@ -733,7 +739,7 @@ module Parse
|
|
|
733
739
|
obj = payload.parse_object
|
|
734
740
|
return unless obj.is_a?(Parse::Object)
|
|
735
741
|
return unless route_registered?(:after_delete, payload.parse_class)
|
|
736
|
-
return if payload.ruby_initiated? && payload.master?
|
|
742
|
+
return if payload.ruby_initiated? && payload.master?
|
|
737
743
|
obj.run_after_delete_callbacks
|
|
738
744
|
nil
|
|
739
745
|
rescue => e
|
|
@@ -779,18 +785,23 @@ module Parse
|
|
|
779
785
|
#
|
|
780
786
|
# Fields are compared against a fresh build of the payload, so only the
|
|
781
787
|
# fields the handler actually changed are encoded from the Ruby object.
|
|
782
|
-
#
|
|
783
|
-
# sub-key (`"meta.count"`) reaches the webhook only
|
|
784
|
-
# sub-document,
|
|
785
|
-
#
|
|
786
|
-
#
|
|
788
|
+
# A field the handler rewrites is written as an absolute value. An
|
|
789
|
+
# operator on a dotted sub-key (`"meta.count"`) reaches the webhook only
|
|
790
|
+
# as its resulting sub-document, so on an update a changed sub-document
|
|
791
|
+
# is written back as dotted keys for just the sub-keys that differ from
|
|
792
|
+
# the stored one. A concurrent write to another sub-key survives; the
|
|
793
|
+
# changed sub-key itself is written as its resulting value. The split
|
|
794
|
+
# is one level deep (a changed sub-key is written whole at
|
|
795
|
+
# `field.sub`), and a field whose writes would carry a typed value is
|
|
796
|
+
# written whole.
|
|
787
797
|
#
|
|
788
798
|
# @param payload [Parse::Webhooks::Payload] the beforeSave payload.
|
|
789
799
|
# @param obj [Parse::Object, nil] the handler's object (nil when none).
|
|
790
800
|
# @param overrides [Hash, nil] field values a handler returned as a Hash.
|
|
791
801
|
# @param include_create_defaults [Boolean] on a create, also write the
|
|
792
|
-
# object's dirty fields the client did not send (declared defaults
|
|
793
|
-
#
|
|
802
|
+
# object's dirty fields the client did not send (declared defaults and
|
|
803
|
+
# the ACL the class policy resolves), matching what an SDK-side create
|
|
804
|
+
# sends.
|
|
794
805
|
# @return [Hash, nil] the reply object, or nil for "unchanged".
|
|
795
806
|
def before_save_reply(payload, obj, overrides: nil, include_create_defaults: false)
|
|
796
807
|
return nil unless payload && payload.object?
|
|
@@ -806,27 +817,238 @@ module Parse
|
|
|
806
817
|
if include_create_defaults && raw_original.nil?
|
|
807
818
|
client_keys = raw_object.keys.map(&:to_s)
|
|
808
819
|
dirty = obj.changed.map(&:to_sym) & snapshot_fields(obj)
|
|
820
|
+
# This includes the ACL. A create with no `ACL` key is stored by
|
|
821
|
+
# Parse Server as public read and write, so a class with an ACL
|
|
822
|
+
# policy always replies with the ACL that policy resolved (owned
|
|
823
|
+
# by the requesting user where the policy names an owner; see
|
|
824
|
+
# {adopt_request_user_as_acl_owner!}) or the ACL the handler set.
|
|
825
|
+
# An ACL the client sent is kept as sent.
|
|
809
826
|
wire_values(obj, dirty).each do |remote, value|
|
|
810
827
|
next if client_keys.include?(remote) || changes.key?(remote)
|
|
811
828
|
changes[remote] = value
|
|
812
829
|
end
|
|
813
830
|
end
|
|
831
|
+
# An ACL the handler assigned on a create is written even when it
|
|
832
|
+
# equals the default stamp or the client's ACL, whatever the handler
|
|
833
|
+
# returned (the object, `true`, `nil`, or a Hash, whose own `ACL`
|
|
834
|
+
# still wins below). Diffing alone misses `obj.acl = Parse::ACL.new`
|
|
835
|
+
# under a `{}` default, and a create with no `ACL` key is stored
|
|
836
|
+
# public read and write.
|
|
837
|
+
if handler_assigned_acl?(obj) && !changes.key?("ACL")
|
|
838
|
+
changes.merge!(wire_values(obj, [:acl]).slice("ACL"))
|
|
839
|
+
end
|
|
814
840
|
end
|
|
815
841
|
overrides = overrides.as_json if overrides.is_a?(Hash)
|
|
816
842
|
return nil if changes.empty? && drops.empty? && overrides.blank?
|
|
817
843
|
|
|
818
844
|
reply = client_write_data(raw_object, raw_original)
|
|
845
|
+
overrides = overrides.present? ? remote_override_keys(payload, overrides) : {}
|
|
846
|
+
# A field the handler wrote or dropped replaces the client's dotted
|
|
847
|
+
# sub-key writes for it; MongoDB refuses `meta` and `meta.x` together.
|
|
848
|
+
(drops + changes.keys + overrides.keys).each do |remote|
|
|
849
|
+
reply.delete_if { |key, _| key.start_with?("#{remote}.") }
|
|
850
|
+
end
|
|
819
851
|
drops.each { |remote| reply.delete(remote) }
|
|
820
852
|
reply.merge!(changes)
|
|
821
|
-
reply.merge!(overrides
|
|
853
|
+
reply.merge!(overrides)
|
|
854
|
+
fold_dotted_overrides!(reply, overrides.keys, raw_object: raw_object, create: raw_original.nil?)
|
|
855
|
+
reply
|
|
856
|
+
end
|
|
857
|
+
|
|
858
|
+
# A handler's Hash override keyed by Ruby names (`{acl: ...}`,
|
|
859
|
+
# `{author_name: ...}`) is mapped to the remote field names the reply
|
|
860
|
+
# uses, so it replaces the matching field instead of sitting beside it.
|
|
861
|
+
# A dotted key maps its first segment.
|
|
862
|
+
# @!visibility private
|
|
863
|
+
def remote_override_keys(payload, overrides)
|
|
864
|
+
klass = begin
|
|
865
|
+
name = payload.parse_class
|
|
866
|
+
name.present? ? Parse::Object.find_class(name) : nil
|
|
867
|
+
rescue StandardError
|
|
868
|
+
nil
|
|
869
|
+
end
|
|
870
|
+
map = klass.respond_to?(:field_map) ? klass.field_map : {}
|
|
871
|
+
overrides.each_with_object({}) do |(key, value), out|
|
|
872
|
+
key = key.to_s
|
|
873
|
+
head, rest = key.split(".", 2)
|
|
874
|
+
remote = map[head.to_sym]
|
|
875
|
+
head = remote.to_s if remote
|
|
876
|
+
out[rest ? "#{head}.#{rest}" : head] = value
|
|
877
|
+
end
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
# A handler's Hash override may name a sub-key (`"meta.y"`) of a field
|
|
881
|
+
# the reply writes whole (a create, or a field written whole on an
|
|
882
|
+
# update). MongoDB refuses `meta` and `meta.y` in one update, so the
|
|
883
|
+
# sub-key write is folded into the whole value. An override deeper
|
|
884
|
+
# than one level (`"meta.count.value"`) is folded into a `field.sub`
|
|
885
|
+
# write seeded from the pending object, because Parse Server rebuilds
|
|
886
|
+
# the afterSave object from a dotted key only one level deep. On a
|
|
887
|
+
# create every dotted override folds into its whole field.
|
|
888
|
+
#
|
|
889
|
+
# When the value it folds into is not a plain sub-document, a client's
|
|
890
|
+
# value is replaced by the handler's sub-key, and two of the handler's
|
|
891
|
+
# own overrides that conflict (`"meta.a" => 5` with `"meta.a.b" => 6`)
|
|
892
|
+
# raise {ResponseError}.
|
|
893
|
+
# @!visibility private
|
|
894
|
+
def fold_dotted_overrides!(reply, override_keys, raw_object: {}, create: false)
|
|
895
|
+
handler_keys = override_keys.to_set
|
|
896
|
+
override_keys.sort_by { |k| k.count(".") }.each do |key|
|
|
897
|
+
next unless key.include?(".") && reply.key?(key)
|
|
898
|
+
segments = key.split(".")
|
|
899
|
+
# The nearest ancestor path the reply writes whole, if any.
|
|
900
|
+
parent = (1...segments.length).map { |n| segments.first(n).join(".") }.reverse.find { |p| reply.key?(p) }
|
|
901
|
+
unless parent
|
|
902
|
+
next if !create && segments.length <= 2
|
|
903
|
+
parent = create ? segments.first : segments.first(2).join(".")
|
|
904
|
+
seed = parent.split(".").reduce(raw_object) { |cur, seg| cur.is_a?(Hash) ? cur[seg] : nil }
|
|
905
|
+
reply[parent] = plain_sub_document?(seed) ? seed.deep_dup : {}
|
|
906
|
+
end
|
|
907
|
+
value = reply.delete(key)
|
|
908
|
+
whole = reply[parent]
|
|
909
|
+
if plain_sub_document?(whole)
|
|
910
|
+
whole = whole.deep_dup
|
|
911
|
+
elsif handler_keys.include?(parent)
|
|
912
|
+
raise Parse::Webhooks::ResponseError,
|
|
913
|
+
"before_save reply: #{key} conflicts with #{parent} in the handler's reply"
|
|
914
|
+
else
|
|
915
|
+
whole = {}
|
|
916
|
+
end
|
|
917
|
+
*dirs, leaf = segments.drop(parent.count(".") + 1)
|
|
918
|
+
node = dirs.reduce(whole) do |cur, dir|
|
|
919
|
+
cur[dir] = {} unless plain_sub_document?(cur[dir])
|
|
920
|
+
cur[dir]
|
|
921
|
+
end
|
|
922
|
+
if value.is_a?(Hash) && value.key?("__op")
|
|
923
|
+
# A whole write stores its value as data, so an operator folded
|
|
924
|
+
# into it is applied here rather than written as a Hash.
|
|
925
|
+
result = fold_override_op(key, node[leaf], value)
|
|
926
|
+
if result.equal?(FOLD_DELETE)
|
|
927
|
+
node.delete(leaf)
|
|
928
|
+
else
|
|
929
|
+
node[leaf] = result
|
|
930
|
+
end
|
|
931
|
+
else
|
|
932
|
+
node[leaf] = value
|
|
933
|
+
end
|
|
934
|
+
reply[parent] = whole
|
|
935
|
+
end
|
|
822
936
|
reply
|
|
823
937
|
end
|
|
824
938
|
|
|
939
|
+
# @!visibility private
|
|
940
|
+
FOLD_DELETE = Object.new.freeze
|
|
941
|
+
|
|
942
|
+
# Apply a handler's sub-key operator to the value it replaces inside a
|
|
943
|
+
# sub-document the reply writes whole, with Parse Server's semantics
|
|
944
|
+
# for that operator. Raises {ResponseError} (an `{error}` reply, so
|
|
945
|
+
# Parse Server refuses the save) for an operator that cannot be applied
|
|
946
|
+
# to a plain value, rather than storing the operator Hash as data.
|
|
947
|
+
# @param key [String] the dotted override key, for the error message.
|
|
948
|
+
# @param current [Object] the value at that path in the whole write.
|
|
949
|
+
# @param op [Hash] the operator Hash (`{"__op" => ...}`).
|
|
950
|
+
# @return [Object] the new value, or {FOLD_DELETE} to remove the key.
|
|
951
|
+
# @!visibility private
|
|
952
|
+
def fold_override_op(key, current, op)
|
|
953
|
+
name = op["__op"]
|
|
954
|
+
case name
|
|
955
|
+
when "Delete"
|
|
956
|
+
FOLD_DELETE
|
|
957
|
+
when "Increment"
|
|
958
|
+
amount = op["amount"]
|
|
959
|
+
unless amount.is_a?(Numeric) && (current.nil? || current.is_a?(Numeric))
|
|
960
|
+
raise Parse::Webhooks::ResponseError,
|
|
961
|
+
"before_save reply: cannot apply Increment to #{key} (#{current.class})"
|
|
962
|
+
end
|
|
963
|
+
(current || 0) + amount
|
|
964
|
+
when "Add", "AddUnique", "Remove"
|
|
965
|
+
objects = op["objects"]
|
|
966
|
+
unless objects.is_a?(Array) && (current.nil? || current.is_a?(Array))
|
|
967
|
+
raise Parse::Webhooks::ResponseError,
|
|
968
|
+
"before_save reply: cannot apply #{name} to #{key} (#{current.class})"
|
|
969
|
+
end
|
|
970
|
+
base = current || []
|
|
971
|
+
case name
|
|
972
|
+
when "Add" then base + objects
|
|
973
|
+
when "AddUnique" then base + objects.reject { |o| base.include?(o) }.uniq
|
|
974
|
+
else base.reject { |o| objects.include?(o) }
|
|
975
|
+
end
|
|
976
|
+
else
|
|
977
|
+
raise Parse::Webhooks::ResponseError,
|
|
978
|
+
"before_save reply: operator #{name.inspect} on #{key} cannot be combined " \
|
|
979
|
+
"with a whole write of its parent field"
|
|
980
|
+
end
|
|
981
|
+
end
|
|
982
|
+
|
|
983
|
+
# @!visibility private
|
|
984
|
+
def handler_assigned_acl?(obj)
|
|
985
|
+
return true if obj.instance_variable_get(:@_webhook_reply_acl) == true
|
|
986
|
+
obj.respond_to?(:webhook_handler_acl_assigned?) && obj.webhook_handler_acl_assigned?
|
|
987
|
+
end
|
|
988
|
+
|
|
989
|
+
# On a client create, an owner-based ACL policy (`:owner_else_private`,
|
|
990
|
+
# the shipped default, `:owner_else_public`, `:owner_but_public_read`)
|
|
991
|
+
# resolves its owner the way an SDK create `as:` the requesting user
|
|
992
|
+
# does: when the declared owner field holds no value, the user who made
|
|
993
|
+
# the request owns the record. A request without a user (anonymous, or
|
|
994
|
+
# master key) gets the policy's fallback. Leaves the object alone when
|
|
995
|
+
# the handler or the client already set an ACL.
|
|
996
|
+
# @!visibility private
|
|
997
|
+
def adopt_request_user_as_acl_owner!(payload, obj)
|
|
998
|
+
return unless payload && payload.original.nil?
|
|
999
|
+
if handler_assigned_acl?(obj)
|
|
1000
|
+
# The handler's ACL is final: keep the save-time policy resolver
|
|
1001
|
+
# from replacing it, even when it equals the default stamp.
|
|
1002
|
+
obj.instance_variable_set(:@_acl_pristine, false)
|
|
1003
|
+
return
|
|
1004
|
+
end
|
|
1005
|
+
return unless obj.is_a?(Parse::Object) && obj.instance_variable_get(:@_acl_pristine)
|
|
1006
|
+
return if obj.instance_variable_get(:@_acl_owner_override)
|
|
1007
|
+
klass = obj.class
|
|
1008
|
+
return if klass.respond_to?(:builtin_acl_default_active?) && klass.builtin_acl_default_active?
|
|
1009
|
+
return unless klass.respond_to?(:acl_policy_setting) && klass.acl_policy_setting.to_s.start_with?("owner_")
|
|
1010
|
+
field = klass.acl_owner_field
|
|
1011
|
+
if field == :self
|
|
1012
|
+
adopt_self_owned_acl!(obj, klass)
|
|
1013
|
+
return
|
|
1014
|
+
end
|
|
1015
|
+
# A master-key request is not the user's own write.
|
|
1016
|
+
return if payload.master?
|
|
1017
|
+
user = payload.user
|
|
1018
|
+
return unless user.is_a?(Parse::User) && user.id.present?
|
|
1019
|
+
if field && obj.respond_to?(field)
|
|
1020
|
+
return if obj.send(field).present?
|
|
1021
|
+
end
|
|
1022
|
+
obj.instance_variable_set(:@_acl_owner_override, user)
|
|
1023
|
+
end
|
|
1024
|
+
|
|
1025
|
+
# A self-owned user (`acl_policy ..., owner: :self`) owns its own
|
|
1026
|
+
# record, never the requesting user. Its objectId is assigned by Parse
|
|
1027
|
+
# Server after beforeSave, and Parse Server adds the new user's own
|
|
1028
|
+
# read and write to the ACL of every `_User` create. So the reply
|
|
1029
|
+
# carries the policy's ACL without the owner entry (`{}`, or public
|
|
1030
|
+
# read under `:owner_but_public_read`), which Parse Server completes
|
|
1031
|
+
# with the user's own grant. The save-time resolver is skipped, since
|
|
1032
|
+
# it would pre-generate an objectId the server does not use.
|
|
1033
|
+
# @!visibility private
|
|
1034
|
+
def adopt_self_owned_acl!(obj, klass)
|
|
1035
|
+
acl = if klass.acl_policy_setting == :owner_but_public_read
|
|
1036
|
+
Parse::ACL.everyone(true, false)
|
|
1037
|
+
else
|
|
1038
|
+
Parse::ACL.new
|
|
1039
|
+
end
|
|
1040
|
+
klass.instance_method(:acl=).bind_call(obj, acl)
|
|
1041
|
+
obj.instance_variable_set(:@_acl_pristine, false)
|
|
1042
|
+
obj.instance_variable_set(:@_webhook_reply_acl, true)
|
|
1043
|
+
end
|
|
1044
|
+
|
|
825
1045
|
# The client's write, rebuilt from a beforeSave payload. Parse Server
|
|
826
1046
|
# serializes the pending object with `toJSON()`, which reports every
|
|
827
1047
|
# pending top-level operator as its operator hash, so the operators
|
|
828
1048
|
# survive here as sent. On an update, a field whose value equals the
|
|
829
|
-
# stored one was not written by the client and is left out
|
|
1049
|
+
# stored one was not written by the client and is left out, and a
|
|
1050
|
+
# changed sub-document is split into dotted sub-key writes (see
|
|
1051
|
+
# {sub_document_write}).
|
|
830
1052
|
#
|
|
831
1053
|
# @param raw_object [Hash] the unscrubbed `object` hash.
|
|
832
1054
|
# @param raw_original [Hash, nil] the unscrubbed `original` hash.
|
|
@@ -841,12 +1063,96 @@ module Parse
|
|
|
841
1063
|
# Parse Server drops objectId from an update write itself.
|
|
842
1064
|
next if key == Parse::Model::OBJECT_ID
|
|
843
1065
|
next if raw_original.key?(key) && raw_original[key] == value
|
|
1066
|
+
dotted = sub_document_write(key, value, raw_original[key])
|
|
1067
|
+
if dotted
|
|
1068
|
+
data.merge!(dotted)
|
|
1069
|
+
next
|
|
1070
|
+
end
|
|
844
1071
|
end
|
|
845
1072
|
data[key] = value
|
|
846
1073
|
end
|
|
847
1074
|
data
|
|
848
1075
|
end
|
|
849
1076
|
|
|
1077
|
+
# Keys never split into dotted sub-key writes: their values are
|
|
1078
|
+
# replaced as a whole by Parse Server.
|
|
1079
|
+
# @!visibility private
|
|
1080
|
+
BEFORE_SAVE_REPLY_WHOLE_KEYS = %w[ACL authData].freeze
|
|
1081
|
+
|
|
1082
|
+
# Dotted sub-key writes for a sub-document the client changed.
|
|
1083
|
+
#
|
|
1084
|
+
# Parse Server applies a client's `"meta.count"` operator to its pending
|
|
1085
|
+
# object and sends the webhook only the resulting `meta`, which matches
|
|
1086
|
+
# a whole-object write of the same value. Writing just the sub-keys that
|
|
1087
|
+
# differ from the stored sub-document gives the same result as either
|
|
1088
|
+
# client write, without overwriting sub-keys another request changed in
|
|
1089
|
+
# the meantime. The split is one level deep: a changed sub-key is
|
|
1090
|
+
# written whole at `field.sub`, nested objects included, because Parse
|
|
1091
|
+
# Server rebuilds the afterSave object from a non-operator dotted key
|
|
1092
|
+
# only one level deep (`"meta.count.value"` would set `meta.count` to
|
|
1093
|
+
# the leaf). A sub-key missing from the new value is written as a
|
|
1094
|
+
# `Delete` operator.
|
|
1095
|
+
#
|
|
1096
|
+
# Parse Server stores a dotted value as sent, without the type transform
|
|
1097
|
+
# a whole write gets, so a Date would land as a plain sub-document
|
|
1098
|
+
# instead of a BSON date. When any write would carry a typed value
|
|
1099
|
+
# (`{"__type": ...}`), the field is written whole instead.
|
|
1100
|
+
#
|
|
1101
|
+
# @param key [String] the top-level field name.
|
|
1102
|
+
# @param value [Object] the field's value in the pending object.
|
|
1103
|
+
# @param stored [Object] the field's stored value.
|
|
1104
|
+
# @return [Hash, nil] the dotted writes, or nil to write the field whole.
|
|
1105
|
+
# @!visibility private
|
|
1106
|
+
def sub_document_write(key, value, stored)
|
|
1107
|
+
return nil if key.start_with?("_") || BEFORE_SAVE_REPLY_WHOLE_KEYS.include?(key)
|
|
1108
|
+
return nil unless splittable_level?(value, stored)
|
|
1109
|
+
writes = {}
|
|
1110
|
+
diff_sub_document(key, value, stored, writes)
|
|
1111
|
+
return nil if writes.empty? || writes.values.any? { |v| typed_value?(v) }
|
|
1112
|
+
writes
|
|
1113
|
+
end
|
|
1114
|
+
|
|
1115
|
+
# Collect the one-level dotted writes that turn `stored` into `value`
|
|
1116
|
+
# under `path`.
|
|
1117
|
+
# @!visibility private
|
|
1118
|
+
def diff_sub_document(path, value, stored, writes)
|
|
1119
|
+
(value.keys | stored.keys).each do |sub|
|
|
1120
|
+
new_present = value.key?(sub)
|
|
1121
|
+
old_present = stored.key?(sub)
|
|
1122
|
+
next if new_present && old_present && value[sub] == stored[sub]
|
|
1123
|
+
sub_path = "#{path}.#{sub}"
|
|
1124
|
+
writes[sub_path] = new_present ? value[sub] : { "__op" => "Delete" }
|
|
1125
|
+
end
|
|
1126
|
+
end
|
|
1127
|
+
|
|
1128
|
+
# Whether a pair of values can be diffed key by key: both plain JSON
|
|
1129
|
+
# objects, with sub-keys that are usable as path segments.
|
|
1130
|
+
# @!visibility private
|
|
1131
|
+
def splittable_level?(value, stored)
|
|
1132
|
+
return false unless plain_sub_document?(value) && plain_sub_document?(stored)
|
|
1133
|
+
keys = value.keys | stored.keys
|
|
1134
|
+
return false if keys.empty?
|
|
1135
|
+
keys.none? { |k| k.to_s.empty? || k.to_s.include?(".") || k.to_s.start_with?("$") }
|
|
1136
|
+
end
|
|
1137
|
+
|
|
1138
|
+
# Whether a JSON value is, or contains, a Parse typed value
|
|
1139
|
+
# (`{"__type": ...}`: Date, Bytes, Pointer, File, GeoPoint, ...).
|
|
1140
|
+
# @!visibility private
|
|
1141
|
+
def typed_value?(value)
|
|
1142
|
+
case value
|
|
1143
|
+
when Hash then value.key?("__type") || value.values.any? { |v| typed_value?(v) }
|
|
1144
|
+
when Array then value.any? { |v| typed_value?(v) }
|
|
1145
|
+
else false
|
|
1146
|
+
end
|
|
1147
|
+
end
|
|
1148
|
+
|
|
1149
|
+
# A JSON object field value: a Hash that is not a Parse operator or a
|
|
1150
|
+
# typed value (Pointer, Date, File, GeoPoint, ...).
|
|
1151
|
+
# @!visibility private
|
|
1152
|
+
def plain_sub_document?(value)
|
|
1153
|
+
value.is_a?(Hash) && !value.key?("__op") && !value.key?("__type")
|
|
1154
|
+
end
|
|
1155
|
+
|
|
850
1156
|
# Diff the handler's object against a fresh build of the same payload.
|
|
851
1157
|
#
|
|
852
1158
|
# Only fields that are dirty on either object can differ: a field the
|
|
@@ -1118,7 +1424,12 @@ module Parse
|
|
|
1118
1424
|
request = Rack::Request.new env
|
|
1119
1425
|
response = Rack::Response.new
|
|
1120
1426
|
|
|
1427
|
+
# Whether this request proved it came from Parse Server: the webhook
|
|
1428
|
+
# key matched, or (below) a configured signature verified. Without
|
|
1429
|
+
# either, the body's `master` flag is only a claim.
|
|
1430
|
+
authenticated = false
|
|
1121
1431
|
if self.key.present?
|
|
1432
|
+
authenticated = true
|
|
1122
1433
|
provided_key = request.env[HTTP_PARSE_WEBHOOK].to_s
|
|
1123
1434
|
unless ActiveSupport::SecurityUtils.secure_compare(self.key, provided_key)
|
|
1124
1435
|
puts "[Parse::Webhooks] Invalid Parse-Webhook Key received"
|
|
@@ -1163,7 +1474,7 @@ module Parse
|
|
|
1163
1474
|
# require a fresh HMAC over the body. Done before JSON parsing so
|
|
1164
1475
|
# a malformed payload can't bypass dedup, and before any handler
|
|
1165
1476
|
# runs so side effects aren't repeated.
|
|
1166
|
-
replay_error = ReplayProtection.
|
|
1477
|
+
replay_error, signature_verified = ReplayProtection.check(
|
|
1167
1478
|
request.env,
|
|
1168
1479
|
body_str,
|
|
1169
1480
|
request.env["HTTP_X_PARSE_REQUEST_ID"]
|
|
@@ -1172,6 +1483,10 @@ module Parse
|
|
|
1172
1483
|
response.write error(replay_error)
|
|
1173
1484
|
return response.finish
|
|
1174
1485
|
end
|
|
1486
|
+
# Uses the result of the check that just ran, not a second read of
|
|
1487
|
+
# the signing secret, so a secret changed mid-request cannot mark an
|
|
1488
|
+
# unverified request as authenticated.
|
|
1489
|
+
authenticated ||= signature_verified
|
|
1175
1490
|
|
|
1176
1491
|
# Parse Server registers each trigger at
|
|
1177
1492
|
# `<endpoint>/<triggerName>/<className>`. For beforeFind/afterFind the
|
|
@@ -1184,6 +1499,7 @@ module Parse
|
|
|
1184
1499
|
webhook_class = Parse::Webhooks.trigger_class_from_path(request.path)
|
|
1185
1500
|
begin
|
|
1186
1501
|
payload = Parse::Webhooks::Payload.new(body_str, webhook_class)
|
|
1502
|
+
payload.authenticated = authenticated
|
|
1187
1503
|
rescue => e
|
|
1188
1504
|
warn "Invalid webhook payload format: #{Parse::TerminalSafe.sanitize_line(e.to_s)}"
|
|
1189
1505
|
response.write error("Invalid payload format. Should be valid JSON.")
|