parse-stack-next 5.2.0 → 5.3.0
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/.bundle/config +1 -0
- data/CHANGELOG.md +240 -0
- data/Gemfile.lock +1 -1
- data/README.md +195 -1
- data/Rakefile +193 -40
- data/docs/client_sdk_guide.md +33 -0
- data/docs/mcp_guide.md +135 -4
- data/lib/parse/client.rb +119 -7
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/user.rb +20 -0
- data/lib/parse/model/core/actions.rb +7 -9
- data/lib/parse/model/core/pluralized_aliases.rb +30 -0
- data/lib/parse/model/core/properties.rb +27 -0
- data/lib/parse/model/core/querying.rb +70 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/model/object.rb +41 -0
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +156 -1
- data/lib/parse/webhooks/payload.rb +205 -34
- data/lib/parse/webhooks.rb +15 -3
- metadata +2 -1
|
@@ -64,6 +64,18 @@ module Parse
|
|
|
64
64
|
attr_accessor :master, :user, :installation_id, :params, :function_name, :object, :trigger_name
|
|
65
65
|
attr_accessor :query, :log, :objects
|
|
66
66
|
attr_accessor :original, :update, :raw
|
|
67
|
+
# @!attribute [r] session_token
|
|
68
|
+
# The caller's live Parse session token, captured from the incoming
|
|
69
|
+
# webhook payload (`user.sessionToken`) before credentials are scrubbed
|
|
70
|
+
# from {#user} / {#object} / {#original} / {#update}. Present only when
|
|
71
|
+
# the originating request was made by a logged-in user -- a master-key
|
|
72
|
+
# request carries no user and no token, so this is +nil+. It is
|
|
73
|
+
# intentionally NOT one of {ATTRIBUTES}, so it never appears in
|
|
74
|
+
# {#as_json} or in the redacted request log. Reach for it (or the
|
|
75
|
+
# higher-level {#user_client} / {#user_agent}) only when a handler
|
|
76
|
+
# deliberately wants to act on the server as the calling user.
|
|
77
|
+
# @return [String, nil]
|
|
78
|
+
attr_reader :session_token
|
|
67
79
|
# @!visibility private
|
|
68
80
|
attr_accessor :webhook_class
|
|
69
81
|
alias_method :installationId, :installation_id
|
|
@@ -78,24 +90,45 @@ module Parse
|
|
|
78
90
|
hash = Hash[hash.map { |k, v| [k.to_s.underscore.to_sym, v] }]
|
|
79
91
|
@raw = hash
|
|
80
92
|
@master = hash[:master]
|
|
81
|
-
#
|
|
82
|
-
#
|
|
83
|
-
# user
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
93
|
+
# Capture the caller's session token from the *unscrubbed* user hash
|
|
94
|
+
# before scrub_credentials strips it below. Parse Server includes
|
|
95
|
+
# `user.sessionToken` on every trigger fired by a logged-in caller
|
|
96
|
+
# (it is absent for master-key-originated requests). Pulling it aside
|
|
97
|
+
# here -- rather than leaving it in @user -- keeps it out of any object
|
|
98
|
+
# a handler might persist and out of #as_json / the request log, while
|
|
99
|
+
# still letting a handler opt in to acting as the calling user via
|
|
100
|
+
# #session_token / #user_client / #user_agent.
|
|
101
|
+
@session_token = self.class.extract_session_token(hash[:user])
|
|
102
|
+
# Webhook trigger payloads (beforeSave/afterSave/etc.) are delivered by
|
|
103
|
+
# Parse Server and, when a webhook key is configured (the default; see
|
|
104
|
+
# Parse::Webhooks.allow_unauthenticated for the opt-out used in tests /
|
|
105
|
+
# local dev), authenticated by it -- so they are treated as trusted,
|
|
106
|
+
# server-authoritative state. A handler is meant to receive the full
|
|
107
|
+
# object -- createdAt/updatedAt, ACL, internal fields and all. The only
|
|
108
|
+
# thing stripped here is genuine credential material a handler never
|
|
109
|
+
# legitimately needs to read inline (live session tokens -- captured
|
|
110
|
+
# above for opt-in user-scoped clients first -- and offline-crackable
|
|
111
|
+
# password hashes); see WEBHOOK_TRIGGER_CREDENTIAL_KEYS. Protection
|
|
112
|
+
# against *persisting* forged privileged fields lives on the write path
|
|
113
|
+
# (changes_payload emits only declared, dirty-tracked properties), not on
|
|
114
|
+
# this read path.
|
|
88
115
|
if hash[:user].present?
|
|
89
|
-
|
|
116
|
+
# Trusted hydration via .build (not .new) so server-sent timestamps and
|
|
117
|
+
# data fields remain readable; credentials are removed first. Note
|
|
118
|
+
# Parse::User applies its own protections, so `payload.user.auth_data`
|
|
119
|
+
# is not exposed here. The built object is pristine, so a handler that
|
|
120
|
+
# saves payload.user transmits nothing (no dirty changes) and cannot
|
|
121
|
+
# persist forgeries.
|
|
122
|
+
@user = Parse::User.build(self.class.scrub_credentials(hash[:user]))
|
|
90
123
|
end
|
|
91
124
|
@installation_id = hash[:installation_id]
|
|
92
125
|
@params = hash[:params]
|
|
93
126
|
@params = @params.with_indifferent_access if @params.is_a?(Hash)
|
|
94
127
|
@function_name = hash[:function_name]
|
|
95
|
-
@object = self.class.
|
|
128
|
+
@object = self.class.scrub_credentials(hash[:object])
|
|
96
129
|
@trigger_name = hash[:trigger_name]
|
|
97
|
-
@original = self.class.
|
|
98
|
-
@update = self.class.
|
|
130
|
+
@original = self.class.scrub_credentials(hash[:original])
|
|
131
|
+
@update = self.class.scrub_credentials(hash[:update]) || {}
|
|
99
132
|
# Added for beforeFind and afterFind triggers
|
|
100
133
|
@query = hash[:query]
|
|
101
134
|
@objects = hash[:objects] || []
|
|
@@ -103,34 +136,69 @@ module Parse
|
|
|
103
136
|
end
|
|
104
137
|
|
|
105
138
|
# @!visibility private
|
|
106
|
-
#
|
|
107
|
-
#
|
|
108
|
-
#
|
|
109
|
-
#
|
|
110
|
-
#
|
|
111
|
-
|
|
139
|
+
# Genuine credential material that is stripped from every webhook trigger
|
|
140
|
+
# payload before a handler can see it, even though the rest of the
|
|
141
|
+
# (trusted, server-authoritative) payload passes through untouched. A
|
|
142
|
+
# session token is a live bearer credential; a password hash is
|
|
143
|
+
# offline-crackable. A handler has no legitimate reason to read either,
|
|
144
|
+
# and removing them keeps them out of logs and out of any object a handler
|
|
145
|
+
# might persist. Everything else Parse Server sends -- createdAt/updatedAt,
|
|
146
|
+
# ACL, authData, roles, _rperm/_wperm, internal fields -- is preserved so
|
|
147
|
+
# the handler observes the full object. Write-side protection
|
|
148
|
+
# (changes_payload emits only declared, dirty-tracked properties) is what
|
|
149
|
+
# prevents persisting forged privileged fields.
|
|
150
|
+
WEBHOOK_TRIGGER_CREDENTIAL_KEYS = %w[
|
|
151
|
+
sessionToken session_token
|
|
152
|
+
_hashed_password _password_history
|
|
153
|
+
].freeze
|
|
112
154
|
|
|
113
155
|
# @!visibility private
|
|
114
|
-
# Returns a copy of +obj+ with
|
|
115
|
-
# removed
|
|
116
|
-
# Operates on string and symbol keys (Parse Server uses camelCase
|
|
156
|
+
# Returns a copy of +obj+ with only +WEBHOOK_TRIGGER_CREDENTIAL_KEYS+
|
|
157
|
+
# removed. Operates on string and symbol keys (Parse Server uses camelCase
|
|
117
158
|
# strings on the wire; downstream code may have already symbolized).
|
|
118
159
|
# Pass-through for non-Hash input.
|
|
119
|
-
def self.
|
|
160
|
+
def self.scrub_credentials(obj)
|
|
120
161
|
return obj unless obj.is_a?(Hash)
|
|
121
|
-
denied =
|
|
162
|
+
denied = WEBHOOK_TRIGGER_CREDENTIAL_KEYS
|
|
122
163
|
obj.reject do |k, _|
|
|
123
164
|
name = k.to_s
|
|
124
|
-
next false if PAYLOAD_PRESERVED_KEYS.include?(name)
|
|
125
165
|
denied.include?(name) || denied.include?(name.underscore)
|
|
126
166
|
end
|
|
127
167
|
end
|
|
128
168
|
|
|
169
|
+
# @!visibility private
|
|
170
|
+
# Pulls the caller's session token out of the (unscrubbed) +user+ hash.
|
|
171
|
+
# Parse Server sends it as the camelCase string key +sessionToken+; this
|
|
172
|
+
# tolerates a symbol key and the snake_case form too, mirroring the
|
|
173
|
+
# leniency in +scrub_credentials+. Returns +nil+ for a blank token or a
|
|
174
|
+
# non-Hash / absent user (a master-key request has no user).
|
|
175
|
+
def self.extract_session_token(user_hash)
|
|
176
|
+
return nil unless user_hash.is_a?(Hash)
|
|
177
|
+
token = user_hash["sessionToken"] || user_hash[:sessionToken] ||
|
|
178
|
+
user_hash["session_token"] || user_hash[:session_token]
|
|
179
|
+
token = token.to_s.strip
|
|
180
|
+
token.empty? ? nil : token
|
|
181
|
+
end
|
|
182
|
+
|
|
129
183
|
# @return [ATTRIBUTES]
|
|
130
184
|
def attributes
|
|
131
185
|
ATTRIBUTES
|
|
132
186
|
end
|
|
133
187
|
|
|
188
|
+
# Redacted inspection. The default Ruby `#inspect` would dump every ivar,
|
|
189
|
+
# including the captured `@session_token` and the *pre-scrub* `@raw` hash
|
|
190
|
+
# (which still holds the caller's sessionToken and any password hashes).
|
|
191
|
+
# That is exactly the surface an error reporter or a stray `p payload`
|
|
192
|
+
# hits, so show only non-sensitive routing fields and a boolean for the
|
|
193
|
+
# token's presence. Use #as_json / the individual accessors for the
|
|
194
|
+
# (already credential-scrubbed) object data.
|
|
195
|
+
def inspect
|
|
196
|
+
"#<#{self.class.name} trigger=#{@trigger_name.inspect} " \
|
|
197
|
+
"function=#{@function_name.inspect} class=#{parse_class.inspect} " \
|
|
198
|
+
"id=#{parse_id.inspect} master=#{@master ? true : false} " \
|
|
199
|
+
"session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
|
|
200
|
+
end
|
|
201
|
+
|
|
134
202
|
# Method to print to standard that utilizes the an internal id to make it easier
|
|
135
203
|
# to trace incoming requests.
|
|
136
204
|
def wlog(s)
|
|
@@ -152,6 +220,51 @@ module Parse
|
|
|
152
220
|
@master.present?
|
|
153
221
|
end
|
|
154
222
|
|
|
223
|
+
# true if this payload carried a caller session token -- i.e. the
|
|
224
|
+
# originating request was made by a logged-in user rather than the
|
|
225
|
+
# master key, so {#user_client} / {#user_agent} can act as that user.
|
|
226
|
+
# @return [Boolean]
|
|
227
|
+
def session_token?
|
|
228
|
+
!@session_token.nil?
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# An opt-in, user-scoped {Parse::Client} for acting on the server as the
|
|
232
|
+
# webhook's calling user. It mirrors the default client's connection
|
|
233
|
+
# settings (+server_url+, +application_id+, +api_key+) but carries NO
|
|
234
|
+
# master key and BINDS the caller's {#session_token}, so every request it
|
|
235
|
+
# makes -- with no further ceremony -- is authorized by Parse Server as
|
|
236
|
+
# that user: ACL, CLP and +protectedFields+ are all enforced. (A
|
|
237
|
+
# `Parse.with_session` block still overrides the bound token if you need
|
|
238
|
+
# to act as someone else within a call.) Memoized per payload, since each
|
|
239
|
+
# webhook delivery carries a distinct token.
|
|
240
|
+
# @return [Parse::Client, nil] +nil+ when the payload carried no token.
|
|
241
|
+
def user_client
|
|
242
|
+
return nil if @session_token.nil?
|
|
243
|
+
@user_client ||= Parse::Client.client.become(@session_token)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# An opt-in, non-master {Parse::Agent} scoped to the webhook caller's
|
|
247
|
+
# session token. Because its client has no master key and it is built
|
|
248
|
+
# with a non-empty +session_token:+, the agent runs in CLIENT MODE:
|
|
249
|
+
# every tool/query routes through a path Parse Server (or the SDK's own
|
|
250
|
+
# ACL/CLP enforcement layer) authorizes as the calling user, with no
|
|
251
|
+
# master-key fallback to silently bypass row-level security. This is the
|
|
252
|
+
# handle to use when a handler should read or act strictly within the
|
|
253
|
+
# caller's permissions. Additional agent options (e.g.
|
|
254
|
+
# +permissions: :readwrite+) may be passed through.
|
|
255
|
+
# @param opts [Hash] extra keyword args forwarded to {Parse::Agent#initialize}.
|
|
256
|
+
# @return [Parse::Agent, nil] +nil+ when the payload carried no token.
|
|
257
|
+
def user_agent(**opts)
|
|
258
|
+
return nil if @session_token.nil?
|
|
259
|
+
require_relative "../agent" unless defined?(Parse::Agent)
|
|
260
|
+
# Strip the two identity kwargs from the passthrough: a Ruby double-splat
|
|
261
|
+
# that repeats an explicit keyword WINS, so user_agent(client: master)
|
|
262
|
+
# or user_agent(session_token: other) would otherwise silently defeat the
|
|
263
|
+
# whole point (scoping to the caller). The scoping is non-negotiable here.
|
|
264
|
+
opts = opts.except(:session_token, :client)
|
|
265
|
+
Parse::Agent.new(session_token: @session_token, client: user_client, **opts)
|
|
266
|
+
end
|
|
267
|
+
|
|
155
268
|
# @return [String] the name of the Parse class for this request.
|
|
156
269
|
def parse_class
|
|
157
270
|
return @webhook_class if @webhook_class.present?
|
|
@@ -278,24 +391,82 @@ module Parse
|
|
|
278
391
|
if @original.present? && @original.is_a?(Hash)
|
|
279
392
|
o = Parse::Object.build @original, parse_class
|
|
280
393
|
o.apply_attributes! @object, dirty_track: true
|
|
281
|
-
|
|
282
|
-
if o.is_a?(Parse::User) && @update.present? && @update["authData"].present?
|
|
283
|
-
o.auth_data = @update["authData"]
|
|
284
|
-
end
|
|
285
394
|
return o
|
|
286
395
|
else #else the object must be new
|
|
287
396
|
klass = Parse::Object.find_class parse_class
|
|
288
397
|
# if we have a class, return that with updated changes, otherwise
|
|
289
398
|
# default to regular object
|
|
290
|
-
if klass.present?
|
|
291
|
-
o = klass.new(@object || {})
|
|
292
|
-
if o.is_a?(Parse::User) && @update.present? && @update["authData"].present?
|
|
293
|
-
o.auth_data = @update["authData"]
|
|
294
|
-
end
|
|
295
|
-
return o
|
|
296
|
-
end # if klass.present?
|
|
399
|
+
return klass.new(@object || {}) if klass.present?
|
|
297
400
|
end # if we have original
|
|
298
401
|
end # if before_trigger?
|
|
402
|
+
|
|
403
|
+
# afterSave on an UPDATE: build the prior state, then overlay the final
|
|
404
|
+
# state with dirty tracking so `*_changed?` / `changes` work inside
|
|
405
|
+
# afterSave handlers (symmetric with the beforeSave path above). The
|
|
406
|
+
# filter uses the timestamp-preserving INITIALIZE key set rather than the
|
|
407
|
+
# wide mass-assignment set: the wide set would strip the incoming
|
|
408
|
+
# `updatedAt` from the overlay, leaving the prior `updatedAt` and breaking
|
|
409
|
+
# `existed?`. The diff still excludes credentials / _rperm / _wperm /
|
|
410
|
+
# authData / roles, and an after-trigger response is only true/false, so
|
|
411
|
+
# there is no path for a forged privileged field to be persisted.
|
|
412
|
+
if after_save? && @original.present? && @original.is_a?(Hash)
|
|
413
|
+
o = Parse::Object.build @original, parse_class
|
|
414
|
+
o.apply_attributes! @object, dirty_track: true,
|
|
415
|
+
protected_set: Parse::Properties::PROTECTED_INITIALIZE_KEYS
|
|
416
|
+
return o
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
# afterSave on a CREATE: there is no prior persisted state, so every
|
|
420
|
+
# populated data field is new. Build symmetry with the UPDATE path above
|
|
421
|
+
# by seeding the identity / system fields (objectId, timestamps, ACL,
|
|
422
|
+
# className, plus the credential / permission keys) into a pristine
|
|
423
|
+
# object, then overlaying the full object with dirty tracking. Because
|
|
424
|
+
# the overlay's protected_set is exactly the seed key set, the system
|
|
425
|
+
# fields come ONLY from the clean seed and the overlay touches ONLY
|
|
426
|
+
# declared data properties (nil -> value -> changed) -- so `*_changed?`
|
|
427
|
+
# reports every field the create populated while createdAt / updatedAt /
|
|
428
|
+
# ACL stay clean. This lets handlers key off dirty tracking uniformly
|
|
429
|
+
# across create and update (e.g. building a sync payload from changed
|
|
430
|
+
# fields). Credentials / _rperm / _wperm / authData / roles are filtered
|
|
431
|
+
# from the overlay (seeded read-only, never marked changed), and an
|
|
432
|
+
# after-trigger response is only true/false, so nothing here can persist
|
|
433
|
+
# a forged field.
|
|
434
|
+
if after_save? && @object.is_a?(Hash)
|
|
435
|
+
seed_keys = Parse::Properties::PROTECTED_INITIALIZE_KEYS +
|
|
436
|
+
%w[objectId createdAt updatedAt ACL className __type]
|
|
437
|
+
seed = @object.slice(*seed_keys)
|
|
438
|
+
o = Parse::Object.build seed, parse_class
|
|
439
|
+
# `build` applies declared `default:` values onto the seed and then
|
|
440
|
+
# clears changes, baking each default into the "pristine" baseline.
|
|
441
|
+
# Without correction, the overlay's dirty guard (`unless val ==
|
|
442
|
+
# current`) would SUPPRESS marking any create value equal to its
|
|
443
|
+
# default (e.g. status: "draft", count: 0, archived: false), silently
|
|
444
|
+
# defeating this branch's whole purpose. Reset the default-bearing
|
|
445
|
+
# ivars to nil for the fields the overlay is about to set, then
|
|
446
|
+
# re-clear, so the overlay's guard sees a differing current ivar and
|
|
447
|
+
# marks every populated data field changed. (`*_changed?` / `changes`
|
|
448
|
+
# / `changed` then report the field; for a defaulted field the
|
|
449
|
+
# reported prior value is the default rather than nil, since the
|
|
450
|
+
# getter re-derives the default — its prior *effective* value.)
|
|
451
|
+
# `defaults_list` never contains the seeded system fields
|
|
452
|
+
# (objectId/createdAt/updatedAt/ACL), so this cannot disturb them;
|
|
453
|
+
# defaults for fields ABSENT from the payload are left intact so their
|
|
454
|
+
# value still reads through.
|
|
455
|
+
fmap = o.class.respond_to?(:field_map) ? o.class.field_map : {}
|
|
456
|
+
o.class.defaults_list.each do |k|
|
|
457
|
+
wire = (fmap[k] || k).to_s
|
|
458
|
+
next unless @object.key?(wire) || @object.key?(k.to_s)
|
|
459
|
+
o.instance_variable_set(:"@#{k}", nil)
|
|
460
|
+
end
|
|
461
|
+
o.clear_changes!
|
|
462
|
+
o.apply_attributes! @object, dirty_track: true, protected_set: seed_keys
|
|
463
|
+
return o
|
|
464
|
+
end
|
|
465
|
+
|
|
466
|
+
# Every other trigger (afterDelete, afterFind, and any before* path that
|
|
467
|
+
# did not match above): the full object as the server sent it.
|
|
468
|
+
# createdAt/updatedAt survive (only credentials are scrubbed), so
|
|
469
|
+
# `new?` / `existed?` read correctly.
|
|
299
470
|
Parse::Object.build(@object, parse_class)
|
|
300
471
|
end
|
|
301
472
|
|
data/lib/parse/webhooks.rb
CHANGED
|
@@ -233,11 +233,23 @@ module Parse
|
|
|
233
233
|
# ran ActiveModel before_save callbacks locally. A client-spoofed
|
|
234
234
|
# `_RB_` without master falls through and runs them here.
|
|
235
235
|
unless trusted_ruby_initiated
|
|
236
|
-
|
|
237
|
-
# If
|
|
238
|
-
if
|
|
236
|
+
before_save_result = result.run_before_save_callbacks
|
|
237
|
+
# If a before_save callback halted the chain (returned false), reject the save.
|
|
238
|
+
if before_save_result == false
|
|
239
239
|
raise Parse::Webhooks::ResponseError, "Save halted by before_save callback"
|
|
240
240
|
end
|
|
241
|
+
# Parse Server exposes no separate beforeCreate trigger, so the
|
|
242
|
+
# beforeSave hook is the single point at which before_create must
|
|
243
|
+
# run for a client-initiated create. Run it AFTER before_save, for
|
|
244
|
+
# new objects only -- matching ActiveModel order (before_save wraps
|
|
245
|
+
# before_create) and mirroring the afterSave hook, which runs
|
|
246
|
+
# after_create then after_save. `original.nil?` marks a create.
|
|
247
|
+
if payload && payload.original.nil?
|
|
248
|
+
create_result = result.run_before_create_callbacks
|
|
249
|
+
if create_result == false
|
|
250
|
+
raise Parse::Webhooks::ResponseError, "Save halted by before_create callback"
|
|
251
|
+
end
|
|
252
|
+
end
|
|
241
253
|
end
|
|
242
254
|
# For before_save, return the changes payload (what Parse Server expects)
|
|
243
255
|
result = result.changes_payload
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: parse-stack-next
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Adrian Curtin
|
|
@@ -364,6 +364,7 @@ files:
|
|
|
364
364
|
- lib/parse/model/core/field_guards.rb
|
|
365
365
|
- lib/parse/model/core/indexing.rb
|
|
366
366
|
- lib/parse/model/core/parse_reference.rb
|
|
367
|
+
- lib/parse/model/core/pluralized_aliases.rb
|
|
367
368
|
- lib/parse/model/core/properties.rb
|
|
368
369
|
- lib/parse/model/core/querying.rb
|
|
369
370
|
- lib/parse/model/core/schema.rb
|