Skip to content

Preserve full Parse object in webhooks (v5.2.1)#13

Merged
AdrianCurtin merged 1 commit into
mainfrom
dev
Jun 5, 2026
Merged

Preserve full Parse object in webhooks (v5.2.1)#13
AdrianCurtin merged 1 commit into
mainfrom
dev

Conversation

@AdrianCurtin
Copy link
Copy Markdown
Contributor

Treat webhook trigger payloads as server-authoritative: preserve timestamps, ACL, authData, roles and internal fields while stripping only genuine credentials (sessionToken, _hashed_password, _password_history). Replace broad mass-assignment scrubbing with a targeted scrub_credentials path, hydrate trusted objects with .build so handlers can read createdAt/updatedAt and auth fields, and enable dirty-tracking overlay for afterSave updates so *_changed?/changes work inside afterSave. Updates include: changelog and version bump to 5.2.1, docs describing listening-stream ownership, payload parsing changes in lib/parse/webhooks/payload.rb, security test adjustments, and a new suite of unit and integration tests that lock in full-object webhook fidelity and write-side guarantees.

Copilot AI review requested due to automatic review settings June 5, 2026 01:19
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the webhook trigger payload pipeline so webhook handlers can observe a more server-faithful Parse::Object (timestamps/ACL/etc.) and adds regression coverage to lock in afterSave semantics (new vs. update + dirty tracking). It also bumps the gem version to 5.2.1 and updates documentation/changelog to describe the new behavior.

Changes:

  • Replace broad webhook payload scrubbing with a credential-only scrub and adjust hydration to preserve more server-sent fields.
  • Add afterSave update “overlay” logic intended to enable dirty tracking (*_changed?, changed, changes) inside afterSave handlers.
  • Add unit + Docker integration test suites covering full-object fidelity and afterSave state semantics; update docs/changelog/version accordingly.

Reviewed changes

Copilot reviewed 8 out of 9 changed files in this pull request and generated 7 comments.

Show a summary per file
File Description
lib/parse/webhooks/payload.rb Narrows webhook payload scrubbing and changes hydration/afterSave overlay behavior to preserve server fields and enable dirty tracking.
test/lib/parse/webhook_aftersave_state_integration_test.rb New Docker end-to-end integration coverage capturing afterSave object state across create/update flows.
test/lib/parse/webhook_aftersave_payload_fidelity_test.rb New unit-level regression suite asserting full-object fidelity + afterSave dirty tracking behavior.
test/lib/parse/security_hardening_test.rb Updates security expectations around webhook payload scrubbing and “write-side” defenses.
test/lib/parse/agent_integration_test.rb Adjusts schema error assertion text expectation.
docs/mcp_guide.md Documents listening-stream ownership model and principal_resolver: behavior.
CHANGELOG.md Adds 5.2.1 entry documenting webhook payload fidelity + afterSave dirty tracking.
lib/parse/stack/version.rb Bumps version to 5.2.1.
Gemfile.lock Updates locked gem version to 5.2.1.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 91 to 97
if hash[:user].present?
@user = Parse::User.new(self.class.scrub_protected_keys(hash[:user]))
# Trusted hydration via .build (not .new) so server-sent authData /
# roles / timestamps remain readable; credentials are removed first.
# The built object is pristine, so a handler that saves payload.user
# transmits nothing (no dirty changes) and cannot persist forgeries.
@user = Parse::User.build(self.class.scrub_credentials(hash[:user]))
end
Comment thread lib/parse/webhooks/payload.rb Outdated
Comment on lines +81 to +86
# Webhook trigger payloads (beforeSave/afterSave/etc.) are delivered by
# Parse Server itself and authenticated by the webhook key, so they are
# trusted, server-authoritative state. A handler is meant to receive the
# FULL object -- createdAt/updatedAt, ACL, authData, roles, _rperm/_wperm,
# internal fields and all. The only thing stripped here is genuine
# credential material a handler never legitimately needs to read (live
Comment on lines +304 to +317
# afterSave on an UPDATE: build the prior state, then overlay the final
# state with dirty tracking so `*_changed?` / `changes` work inside
# afterSave handlers (symmetric with the beforeSave path above). The
# filter uses the timestamp-preserving INITIALIZE key set rather than the
# wide mass-assignment set: the wide set would strip the incoming
# `updatedAt` from the overlay, leaving the prior `updatedAt` and breaking
# `existed?`. The diff still excludes credentials / _rperm / _wperm /
# authData / roles, and an after-trigger response is only true/false, so
# there is no path for a forged privileged field to be persisted.
if after_save? && @original.present? && @original.is_a?(Hash)
o = Parse::Object.build @original, parse_class
o.apply_attributes! @object, dirty_track: true,
protected_set: Parse::Properties::PROTECTED_INITIALIZE_KEYS
return o
Comment on lines +294 to +300
assert_empty missing,
"Webhook object dropped server-sent field(s) #{missing.inspect}. " \
"Parse Server sent #{raw_object_hash.keys.sort.inspect}; the built " \
"object must expose every data field. A non-empty diff means " \
"over-broad scrubbing was (re)introduced on the webhook path " \
"(see Parse::Webhooks::Payload#scrub_protected_keys)."
end
Comment on lines +191 to +195
# NOTE: several assertions below are SPEC assertions for the proposed fix
# (the webhook path must stop stripping server-authoritative
# createdAt/updatedAt via Parse::Webhooks::Payload#scrub_protected_keys).
# They FAIL on current HEAD and pass once the scrub is narrowed. The unit
# suite in webhook_aftersave_payload_fidelity_test.rb documents the same
Comment on lines +100 to +107
# ---- DIAGNOSTIC PASS: capture, print, do not assert behavior we are unsure
# of. Convert to hard assertions once the actual values are known. ----

def test_new_object_detection_signals_on_nonmaster_create
captured = {}

Parse::Webhooks.route(:after_save, "WebhookStatePost") do
obj = parse_object
Comment on lines +146 to +154
def test_change_detection_signals_on_nonmaster_update
# Seed an object as master so it already exists, then mutate one field
# as a non-master client and inspect dirty state in afterSave.
id = master_create("WebhookStatePost", { "title" => "original-title", "status" => "draft", "body" => "v1" })

captured = {}
Parse::Webhooks.route(:after_save, "WebhookStatePost") do
obj = parse_object
orig = original_parse_object
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 4 comments.

Comment on lines +315 to +316
o.apply_attributes! @object, dirty_track: true,
protected_set: Parse::Properties::PROTECTED_INITIALIZE_KEYS
Comment thread lib/parse/model/object.rb Outdated
Comment on lines +2076 to +2092
# Helper to run the :before callbacks from a compiled list, in registration
# order, stopping and returning false if any returns false (matching the
# model's `result == false` terminator). Unlike `run_callbacks(:save) { ... }`
# this runs ONLY the before phase, never the after-phase callbacks that a
# block-wrapped run_callbacks would also fire.
def run_before_callbacks_from_list(callbacks)
callbacks.each do |callback|
next unless callback.kind == :before
result = case callback.filter
when Symbol then send(callback.filter)
when Proc then instance_exec(&callback.filter)
when String then instance_eval(callback.filter)
else true
end
return false if result == false
end
true
"Parse Server sent #{raw_object_hash.keys.sort.inspect}; the built " \
"object must expose every data field. A non-empty diff means " \
"over-broad scrubbing was (re)introduced on the webhook path " \
"(see Parse::Webhooks::Payload#scrub_protected_keys)."
Comment on lines +191 to +194
# NOTE: several assertions below are SPEC assertions for the proposed fix
# (the webhook path must stop stripping server-authoritative
# createdAt/updatedAt via Parse::Webhooks::Payload#scrub_protected_keys).
# They FAIL on current HEAD and pass once the scrub is narrowed. The unit
@AdrianCurtin AdrianCurtin force-pushed the dev branch 5 times, most recently from 0c9a35e to 8399fcb Compare June 5, 2026 02:05
Treat webhook trigger payloads as server-authoritative: preserve timestamps, ACL, authData, roles and internal fields while stripping only genuine credentials (sessionToken, _hashed_password, _password_history). Replace broad mass-assignment scrubbing with a targeted scrub_credentials path, hydrate trusted objects with .build so handlers can read createdAt/updatedAt and auth fields, and enable dirty-tracking overlay for afterSave updates so *_changed?/changes work inside afterSave. Updates include: changelog and version bump to 5.2.1, docs describing listening-stream ownership, payload parsing changes in lib/parse/webhooks/payload.rb, security test adjustments, and a new suite of unit and integration tests that lock in full-object webhook fidelity and write-side guarantees.
@AdrianCurtin AdrianCurtin requested a review from Copilot June 5, 2026 02:09
@AdrianCurtin AdrianCurtin merged commit f3153d5 into main Jun 5, 2026
11 of 12 checks passed
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 14 changed files in this pull request and generated 3 comments.

Comment on lines +317 to +321
if after_save? && @original.present? && @original.is_a?(Hash)
o = Parse::Object.build @original, parse_class
o.apply_attributes! @object, dirty_track: true,
protected_set: Parse::Properties::PROTECTED_INITIALIZE_KEYS
return o
Comment thread README.md
Comment on lines +4830 to +4832
You can register webhooks to handle the different object triggers: `:before_save`, `:after_save`, `:before_delete` and `:after_delete`. The `payload` object, which is an instance of `Parse::Webhooks::Payload`, contains several properties that represent the payload. One of the most important ones is `parse_object`, which will provide you with the instance of your specific Parse object.

The `parse_object` handed to your handler is the **full object as Parse Server sent it** — `createdAt`/`updatedAt`, `ACL`, and internal fields all survive (only live credentials — session tokens and password hashes — are stripped; `Parse::User` additionally protects `authData` on `payload.user`). Both `:before_save` and `:after_save` objects carry **dirty tracking** of what changed (`name_changed?`, `changes`), and `Parse::Object#existed?` / `#new?` are reliable inside `:after_save`. See [Trigger object state](#trigger-object-state) below.
Comment on lines +118 to +122
captured[:fired] = true
captured[:payload_original_nil] = original.nil?
raw_obj = @raw.is_a?(Hash) ? @raw[:object] : nil
captured[:raw_object_keys] = raw_obj.is_a?(Hash) ? raw_obj.keys.sort : nil
captured[:raw_createdAt] = raw_obj.is_a?(Hash) ? raw_obj["createdAt"] : nil
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants