Skip to content

v5.8.1 - Webhook ACLs, Query Authority, Session Revocation, Search Ownership - #62

Merged
AdrianCurtin merged 19 commits into
mainfrom
release/5.8.1
Oct 7, 2026
Merged

AdrianCurtin merged 19 commits into
mainfrom
release/5.8.1

Conversation

@AdrianCurtin

@AdrianCurtin AdrianCurtin commented Oct 7, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Release 5.8.1: a correctness release that closes the follow-ups left from 5.8.0. It covers webhook create ACLs and sub-document writes, session revocation and Parse.without_master_key parity, query OR compilation and authority, relation change tracking, search ownership filtering, and sub-agent policy narrowing. There are no new defaults and no API changes; every corrected outcome is listed in the CHANGELOG under Behavior Notes.

Webhooks

  • A client create through a before_save handler that returns parse_object gets the ACL its class policy resolves, as an SDK create would. Owner policies take the requesting user as owner when the owner field is empty, so a signed-in client can read its own record (it used to get {} under :owner_else_private). An anonymous create gets the declared fallback, never public write.
  • A master-key request gets the policy fallback, and a _User class with owner: :self is owned by the new user, never the requester. A guard :acl, :master_only revert returns the ACL to the class policy.
  • An ACL the handler assigns (through acl= or mass assignment) always wins, even when it equals the class default, and whatever the handler returns (parse_object, true, nil, or a Hash without ACL).
  • When the handler changes a record, changed sub-documents are written back as one-level dotted paths (Parse Server rebuilds dotted keys only one level for afterSave), so concurrent writes to sibling sub-keys survive. Arrays and typed values (Date, Bytes, Pointer) are written whole. Dotted overrides fold into the write they belong to with operators applied, and unsupported or conflicting overrides fail the save.

Sessions and master-key scope

  • Deleting sessions or users, singly or through Array#destroy, drops their cached identities when the delete succeeds or the row is already gone; a denied delete leaves the cache alone. A session missing its token or owner is looked up first (one query per batch, with the delete's own session when the client has no master key). A lookup with no row forgets nothing; only a failed lookup resets the identity cache, at most once every 5 seconds per client. The looked-up token never stays on the object or enters the response cache, and a token resolution in flight across an invalidation does not cache its answer.
  • Inside Parse.without_master_key, an explicit master: true to Parse::MongoDB.aggregate, Parse::ACLScope, direct query reads, vector and hybrid search, Atlas Search, and master-key agent tools runs in the public scope (or raises under either strict flag), and the aggregate notification reports master_dropped: true.
  • SDK metadata reads (CLP schema, role graph, index stats, Atlas index listing) keep the master key through a private sentinel instead of re-enabling it for the whole call. A schema read refused inside the block is no longer negative-cached; that had denied every scoped mongo-direct read of the class, for all callers, for 5 seconds.
  • faceted_search raises inside the block. The metadata marker rides only on the outgoing request copy, never on response.request. A LiveQuery admin client that first connected inside the block keeps withholding its master key on reconnect until allow_master_key_connection!.
  • Signup and upgrade_anonymous! store timestamps as Parse::Date, so reading them no longer dirties the user.

Queries, LiveQuery, and push

  • An OR with an unconstrained branch matches every row (an empty receiver of | still starts the OR, so reduce(Model.query, :|) works), and :or => [...] builds a real $or.
  • Pipeline-only constraints (readable_by, array_size, set_equals, mongo-direct geo operators) raise inside any OR, in subqueries, in LiveQuery subscriptions, and in push targeting, instead of being dropped and widening the result to every row.
  • clone, |, or_where, Parse::Query.or, :or branches (Hash or query), and $inQuery/$select subqueries keep the query's authority and application: session token, become client, scoped user or role, master flag, read preference, and the target app. Different authorities or applications raise, the authority is pinned whichever operand carries it, a rejected change leaves the query untouched, and a combined query re-checks its authority (including explicit auth passed to direct or Atlas reads) before it runs. Subscriptions run under the query's own session.
  • A bare objectId compared to a declared pointer field matches on REST, LiveQuery subscriptions, and push targeting (new Query#compile_rest_where).
  • A repeated order field is sent once, and "-field" strings parse as descending.

Associations

  • Fetches (fetch!, partial fetch, autofetch, with or without preserve_changes:) keep a relation's staged additions and removals and leave it marked changed, including when the response carries the Relation descriptor. reload! and clear_changes! discard them.
  • rollback! drops staged relation changes, including after a fetch, and a relation kept across a fetch re-reads its list.
  • RelationCollectionProxy#save sends the staged operations (it silently sent nothing) and accepts session:, and atomic relation operations target the remote column.
  • Pointer collections adopt the array Parse Server returns only when every entry is a pointer to the declared class.

Search

  • Atlas Search, autocomplete, and native hybrid search apply the CLP pointerFields / readUserFields ownership constraint before $limit, so scoped callers no longer get short or empty pages when other users' rows rank first.
  • Vector search enforces ownership server-side and pushes it into $vectorSearch.filter when every owner field is a declared scalar pointer that is filter-indexed in a READY index serving its latest definition. A refusal naming an owner path is retried once without the prefilter, a caller's own unindexed filter no longer disables it for everyone, and failed index lookups are negatively cached.

Agent policies

  • A sub-agent's tools: and methods: filters only narrow its parent's (they used to replace them), and a sub-agent cannot turn on master_atlas when its parent does not have it. Sub-agents use the parent's approval gate at call time, including through the MCP dispatcher, qualified methods: entries match class aliases and Ruby constant names, and describe and the audit payload report inherited method layers.

Behavior changes for upgrading apps

CHANGELOG.md lists each one. The ones most likely to be noticed:

  • An OR with an unconstrained branch returns every row; pipeline-only constraints inside an OR raise.
  • Combining queries under different authorities or applications raises ArgumentError, as does changing a combined query's authority.
  • LiveQuery subscriptions, push targeting, and subqueries with pipeline-only constraints raise.
  • REST, LiveQuery, and push comparisons of a bare objectId to a declared pointer field now match.
  • Explicit master: true inside Parse.without_master_key no longer runs as master; wrap such calls in Parse.with_master_key.
  • A sub-agent only: list that leaves nothing once the parent's filters apply raises, as does master_atlas: true under a parent without it.
  • before_save create replies carry the class-policy ACL.

Testing

  • bundle exec rake test:unit passes on the final commit.
  • Integration files for every changed area pass against Parse Server 9.10.3 and MongoDB 9.0.2, including new live tests for webhook create ACLs, concurrent sub-key writes, and afterSave shape, pointer-array adoption and relation saves after a fetch, and REST pointer coercion and OR compilation. The transaction batch-limits test hits Parse Server's concurrent-session transaction abort intermittently on both this branch and the previous commit.
  • Each review finding was reproduced before its fix, and the regression tests fail on the previous code.

Inside Parse.without_master_key an explicit master: true to
Parse::ACLScope, Parse::MongoDB.aggregate, vector and hybrid search,
Atlas Search, or agent tools for master-key agents now resolves to the
public scope (or raises ACLRequired under either strict flag), and the
parse.mongodb.aggregate notification reports master_dropped: true. SDK
metadata reads keep the master key through a private sentinel instead
of toggling fiber state: the CLP schema fetch, the role-graph walk,
index statistics, and the Atlas Search index listing. A schema read
refused inside the block is no longer negative-cached, which had denied
every scoped mongo-direct read of the class for all callers for 5s.
faceted_search raises inside the block, and a LiveQuery admin
connection opened there sends no master key.

Deleting sessions or users, singly or through Array#destroy, now drops
their identity-cache entries on every attempted delete. A session
missing its token or owner is looked up first (one query per batch);
if that fails the client's identity cache is reset. The looked-up
token is cleared from the object on every path. Signup and
upgrade_anonymous! store timestamps as Parse::Date so reading them no
longer dirties the user.
Atlas Search, autocomplete, and native hybrid search apply the CLP
pointerFields / readUserFields constraint as a $match before $limit
(and inside each hybrid input), so a scoped caller no longer gets a
short or empty page when other users' rows rank first. Vector search
enforces ownership server-side across the candidate window and pushes
it into $vectorSearch.filter when every owner field is a declared
scalar pointer filter-indexed in a READY index serving its latest
definition. A refused prefilter is retried once without it and skipped
for the cache TTL, and a failed index lookup is negatively cached.
An OR with an unconstrained branch matches every row (an empty
receiver of | still starts the OR), :or => [...] builds a real $or,
and pipeline-only constraints raise inside any OR instead of compiling
to {}. A repeated order field is sent once and "-field" strings parse
as descending. A bare objectId on a declared pointer field is sent as
a Pointer on REST, LiveQuery subscriptions, and push targeting via the
new Query#compile_rest_where.

clone, |, or_where, Parse::Query.or, and :or query branches keep the
query's authority (session token, become client, scoped user or role,
master flag, read preference). Different authorities raise, a rejected
change leaves the query untouched, and a combined query re-checks its
authority before it runs. Docs use pointer_id / array_size.
…safely

A fetch (including partial fetch, autofetch, and a response carrying a
Relation descriptor) keeps a relation's staged additions and removals
and leaves the relation marked changed. clear_changes! and reload!
discard them. RelationCollectionProxy#save sends the staged ops, and
atomic relation ops target the remote column. Pointer collections
adopt the array Parse Server returns only when every entry is a
pointer to the declared class, falling back to local application.
…y path

A client create through a before_save handler gets the ACL its class
policy resolves, taking the requesting user as owner for owner
policies, so a signed-in client can read its own record and nothing is
left public by omission. An ACL the handler assigns always wins,
whatever the handler returns. When the handler changes a record,
changed sub-documents are written back as dotted paths at any depth,
falling back to a whole write for arrays and typed values, and dotted
overrides fold into whole writes to avoid path conflicts. Webhook
tests now save and restore global webhook state.
A sub-agent's tools: filter intersects its parent's and methods:
filters are kept as layers, so a child can no longer enable a tool or
agent method its parent was not given. Sub-agents use the parent's
approval gate at call time, qualified methods: entries match class
aliases (User and _User), and describe and the audit payload report
inherited method layers.
Bump the version and add the 5.8.1 CHANGELOG entry. Correct the lease
limits in the lock backend comment.
merge_or_scope! pinned the combined authority only when it was adopted
from the incoming query, so alice_query | unscoped left Alice's session
unpinned and a later session_token = nil let the query run as master.
The receiver's own authority is now pinned when the other side has the
default authority, for |, or_where, Parse::Query.or, and :or branches.
@AdrianCurtin AdrianCurtin changed the title Release/5.8.1 v5.8.1 - Webhook ACLs, Query Authority, Session Revocation, Search Ownership Oct 7, 2026
A _User class declared with owner: :self is owned by the new user, never
the requester, and its create no longer pre-generates an objectId the
server never sees. A master-key request does not adopt its user as
owner. A guard :acl revert on a create returns the ACL to the class
policy, and an ACL set through mass assignment counts as handler
assigned. Changed sub-documents are written back one level deep, since
Parse Server rebuilds dotted keys only one level for afterSave. Dotted
override operators are applied when folded into a whole write, and
unsupported or conflicting overrides fail the save. Hash override keys
may use Ruby names, and the webhook test state helper copies routes.
A session lookup that finds no row leaves nothing to forget; only a
lookup that fails resets the identity cache, at most once every 5s per
client. A client without the master key looks sessions up with the
delete's own session. Caches are cleared only when the delete succeeds
or the row is already gone, the lookup bypasses the response cache, and
the destroy hooks are private. A token resolution in flight across an
invalidation no longer caches its answer. The metadata marker rides
only on the outgoing request copy, failed schema reads inside
without_master_key are retried at most every 2s, and the role walk
skips the response cache.
Queries combined with OR, through :or Hash or query branches, or as
$inQuery/$select subqueries must target the same Parse application and
run under the same authority; clone keeps any explicit client. A
rejected composition leaves the receiver unchanged, and explicit auth
passed to direct or Atlas terminals must match a pinned authority.
LiveQuery subscriptions and push targeting refuse pipeline-only
constraints instead of widening to every row, and subscriptions run
under the query's own session or become client. A LiveQuery admin
client that first connected inside without_master_key keeps
withholding its master key on reconnect. order splits comma strings.
rollback! drops staged relation changes, including after a fetch. A
relation proxy kept across a fetch is unloaded so the next read
re-queries the server. Relation saves accept session: and wait for a
client-assigned objectId's create to return. Proxy field lists are
cached per class.
A sub-agent can no longer enable master_atlas under a parent without
it. The MCP dispatcher snapshots an agent's own approval gate so a
sub-agent keeps delegating to its parent. Qualified methods: entries
may name a model by Ruby constant, unresolved entries warn, and the
audit payload reports a single inherited methods layer. Vector search
treats only an owner-path refusal as a prefilter refusal, and native
hybrid uses the default index when none is named.
Comment thread lib/parse/model/classes/session.rb Fixed
Comment thread lib/parse/query.rb Fixed
Comment thread test/lib/parse/webhook_audit_contract_test.rb Fixed
Every invalidation moves a marker before dropping entries, and a token
resolution checks it before and after writing, evicting its own write
when it moved. On a shared plane the marker is a random nonce created
before each lookup, so a reset in another process (which clears the
marker before installing a new one) cannot leave a revoked identity
cached. Session objectId to owner records, kept in their own bounded
store and recorded by the client that fetched the session, let a
delete of an already-gone session invalidate its owner; without one, a
batch delete that applied or found the row gone falls back to the
rate-limited reset, and a denied delete never resets.
A subscription that carries the query's session (an explicit token or
a become client) checks that the LiveQuery client targets the same
Parse application id as the query and raises otherwise, through both
client.subscribe(query) and query.subscribe(client:). _Session query
decoding records session owners against the fetching client.
Initialize locals that CodeQL flagged as possibly uninitialized.
A custom identity plane gets the cross-process revocation-race
guarantee by implementing invalidation_nonce and
bump_invalidation_nonce. One with generation counters only is covered
for single-token and per-user invalidations, but a lookup racing
another process's full reset is bounded by identity_cache_ttl when
its clear also deletes the counters.
… test

with_or_rollback and the webhook test helper now capture their saved
state before an explicit begin, so the rescue/ensure never reads a
variable the capture could have left unset. query_compile_fixes_test
sets up the default client before each test: Parse::Client.new
registers itself as the default when none exists, so under some seeds
an app B client became the default and broke the cross-application
tests (seen on CI macOS Ruby 3.2).
@AdrianCurtin
AdrianCurtin merged commit 2eeba94 into main Oct 7, 2026
14 checks passed
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