parse-stack-next 5.8.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de8e9c2d24346754146f36a8c24b272840456010e08bf86f71b11d38741abad8
4
- data.tar.gz: 83df07a458c9e00cae15f63fe12d4e8c19bc6913f60b297e1fa402985b2073ce
3
+ metadata.gz: d49d6371c1e6873ea48401f789d9087e23870530ae405bf2629ea9db67360995
4
+ data.tar.gz: 9589dfb1ec150a78d527c6ad0097a3ee4263701b104baf7b54afb73e73ec3bdf
5
5
  SHA512:
6
- metadata.gz: ead735d8d48a495293b810f2b6100327869ae68051d9e1aaee87b43d51aeb51b1323e6f29a9b823a40ec0ac15b52bfc91eb838009524239bd7f1f42a63d7e02d
7
- data.tar.gz: 0666b77620a76d2b19a81a5a43f3a9cd61f94661568c269ac07e612c71ff25642c4759d93f0e0b7d7370bb3930447f1fa39ed3eb986fa9bf73a63a072009cbb8
6
+ metadata.gz: 22998b27d0f277d174759d29c5087675628eee3a9a24ed460c82c87deda6a28252ed1d1ad78daee9a2438b2a7b1c4f8995197450d9eb5922b5689e58bda52bdb
7
+ data.tar.gz: 5ef7ef2f07269ca95bbd985c806250ca7d93d02c7c6bccd83a9d72a0c9a57b990bac63e977ddfbd1c601481562fb12d5c0272639ec415787cef0cc76fc4dd3c0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,317 @@
1
1
  ## parse-stack-next Changelog
2
2
 
3
+ ### 5.8.2
4
+
5
+ A security and correctness patch. No new features beyond the `session:`
6
+ arguments and the cache opt-in the fixes need; intentional master-key and
7
+ analytics access is unchanged. Outcomes that change are listed under
8
+ Behavior Notes.
9
+
10
+ #### Transport and MCP authentication
11
+
12
+ - **FIXED**: A whitespace-only MCP API key (`api_key: " "`,
13
+ `MCP_API_KEY=" "`, or a non-breaking-space key) now counts as no key.
14
+ Before, it passed the check that refuses a non-loopback bind without a key
15
+ and then disabled request authentication, leaving a public MCP endpoint
16
+ open. The configured key and the provided `X-MCP-API-Key` header are
17
+ stripped the same way, an explicit blank `api_key:` no longer hides
18
+ `MCP_API_KEY`, and a non-loopback bind with a key shorter than 16
19
+ characters warns.
20
+ - **FIXED**: HTTPS and WebSocket scheme checks parse the URL and compare the
21
+ scheme case-insensitively. Before, `HTTP://` passed `require_https`,
22
+ `HTTPS://` with TLS verification turned off was not refused, and `WS://` got
23
+ past the plaintext LiveQuery guard.
24
+ - **FIXED**: The TLS guard also refuses `ssl: { verify_mode:
25
+ OpenSSL::SSL::VERIFY_NONE }`, `ssl: { verify_hostname: false }`, and a
26
+ `Faraday::SSLOptions` with verification off on an https server URL. Before,
27
+ only a Hash with `verify: false` was caught. A `faraday:` option passed as
28
+ `Faraday::ConnectionOptions` gets the same TLS and proxy guards as a Hash,
29
+ and any other non-Hash value raises.
30
+ - **FIXED**: `Parse::LiveQuery::Client.new` validates an explicit or
31
+ configured URL, as `Parse.setup` does. Plaintext `ws://` to a routable host
32
+ is refused without `allow_insecure`, and a URL whose scheme is not `ws`,
33
+ `wss`, `http`, or `https` raises `ArgumentError`. **CHANGED**: an
34
+ `https://` LiveQuery URL is used as `wss://` (it used to be opened without
35
+ TLS), and an `http://` URL as `ws://` under the same loopback rule, each
36
+ with a one-time deprecation warning.
37
+ - **FIXED**: `Parse::File` URLs hydrated from Parse JSON no longer skip the
38
+ `trusted_url_hosts` check when the scheme is uppercase (`HTTPS://`), the
39
+ URL has leading whitespace, or the URL is malformed (`https:host`,
40
+ `https:\\host`, `https:///host`), all of which browsers resolve to the
41
+ remote host. Under
42
+ `untrusted_url_policy = :raise` or `:strip`, a malformed http(s) URL is
43
+ treated as untrusted. `force_ssl` upgrades `HTTP://` URLs too. Hydration
44
+ reads a URL the way a browser does (tabs and newlines removed, leading
45
+ control characters stripped), checks scheme-relative values (`//host`,
46
+ `\\host`) against `trusted_url_hosts`, and treats `javascript:`, `data:`,
47
+ and other non-http(s) URLs as untrusted.
48
+ - **NEW**: `Parse::File.trust_legacy_tfss_on_any_host = false` requires
49
+ `tfss-` file URLs to come from `trusted_url_hosts`. The default (`true`)
50
+ keeps accepting them from any host.
51
+ - **FIXED**: `Parse::Response#inspect` prints only the status and the
52
+ result's shape, and `#to_s` replaces credential fields with `[FILTERED]`:
53
+ session tokens, passwords, `authData`, access and refresh tokens, API,
54
+ master, client, and JavaScript keys, MFA recovery codes and secrets
55
+ (`recovery`, `recoveryCodes`, `secret`, `authDataResponse`), and
56
+ storage-form columns (`_session_token`, `_hashed_password`,
57
+ `_email_verify_token`, `_perishable_token`, `_password_history`,
58
+ `_auth_data_*`). The same list drives log redaction, which also covers
59
+ header-shaped text (`X-Parse-Master-Key: ...`, `X-Parse-REST-API-Key`,
60
+ `X-Parse-Session-Token`, `X-Parse-Javascript-Key`, `X-Parse-Client-Key`,
61
+ `X-Parse-Webhook-Key`, `Authorization: Bearer ...`). `to_s` also filters
62
+ credential text inside string values (`password=...`), and its error form
63
+ redacts the request line and error text. Text patterns run on string values
64
+ before encoding, and a quoted value is redacted whole (spaces included,
65
+ and after an auth scheme: `password="a b"`, `Authorization: Bearer "x"`),
66
+ so the output stays valid JSON; log redaction of JSON bodies works the same
67
+ way. `#result` is unchanged.
68
+ - **IMPROVED**: Webhook endpoint registration accepts the scheme in any case
69
+ and warns when an `http://` endpoint on a routable host would carry the
70
+ webhook key in cleartext.
71
+
72
+ #### Batches and transactions
73
+
74
+ - **FIXED**: A batch keeps each write's credentials. Parse Server runs every
75
+ sub-request of a `POST /batch` under that call's own credentials and ignores
76
+ per-sub-request headers. Before, `Array#save`, `Array#destroy`,
77
+ `Parse.batch`, and `Parse::Object.transaction` sent everything through the
78
+ default client, so a write built for a user session (a class bound to
79
+ `Parse.client.become(token)`, or a `Parse::Request` with `session_token:` /
80
+ `use_master_key: false`) ran with the master key and bypassed ACLs and CLPs.
81
+ Writes are now sent through their own client and options, and a batch with
82
+ mixed credentials goes out as separate calls with responses in request
83
+ order. Credentials resolve exactly as for a single request: a request's
84
+ `session_token:` / `use_master_key:` options, its `X-Parse-Session-Token`
85
+ header, and its master-key suppression header (which keeps the master key
86
+ off even with `use_master_key: true`). The same rules apply to direct
87
+ `Parse::Client#batch_request` calls. A client chosen explicitly
88
+ (`BatchOperation#client=`, or the client `batch_request` is called on) is
89
+ used for every request, as `Parse::Client#request` uses it for a single
90
+ request; an object's class client applies only to implicit batches
91
+ (`Array#save`, `Array#destroy`, `Parse.batch`). A `use_master_key:` that is
92
+ not exactly `true` or `false` is treated as unset, a `session_token:` that
93
+ resolves to no token fails closed, and narrowing `batch_request` options
94
+ (`use_master_key: false`) override a request's own `use_master_key: true`.
95
+ - **FIXED**: `Parse::Object.transaction` runs with its objects' class client.
96
+ A transaction that mixes credentials raises
97
+ `Parse::BatchOperation::MixedAuthorityError` before anything is sent,
98
+ because Parse Server runs a transaction under one credential and it cannot
99
+ be split. Clients are compared by their credentials (server, application,
100
+ keys, and bound session), not by object identity, so classes whose clients
101
+ were configured identically, such as before and after a second
102
+ `Parse.setup`, still share one transaction. Requests that name their own
103
+ session token are compared without the master key or bound session, so
104
+ `transaction(session:)` across clients that differ only in those is sent as
105
+ one call.
106
+ - **FIXED**: `Array#destroy(session:)` on sessions looks up their tokens and
107
+ owners as that user, so deleting session ids the user cannot see no longer
108
+ evicts other users' cached identities or triggers a reset. A session the
109
+ caller could not read is still treated as deleted when its delete succeeds:
110
+ its recorded owner is invalidated, or the rate-limited reset applies. A
111
+ denied or not-found delete of such a row forgets nothing.
112
+ - **NEW**: `Array#save(session:)`, `Array#destroy(session:)`, and
113
+ `Parse::Object.transaction(session:)` send every write in the call as the
114
+ given user.
115
+
116
+ #### Agents
117
+
118
+ - **FIXED**: A session-token agent whose token could not be resolved when it
119
+ was built (for example, Parse Server unreachable) is no longer treated as
120
+ master-key posture by the SDK-side permission checks. The scope is resolved
121
+ again on first use; if it still cannot be, the call fails with
122
+ `Parse::Agent::UnresolvedIdentity` (reported as `:access_denied`,
123
+ `kind: :unresolved_identity`) instead of skipping the CLP check. Parse
124
+ Server already validated the token on REST calls.
125
+ - **FIXED**: `Parse::Agent#inspect`, `to_s`, and `pp` print a redacted summary
126
+ and no longer show the session token, the client's keys, the conversation,
127
+ or the last request and response.
128
+ - **FIXED**: `Parse::Agent#as_json`, `#to_json`, and `#to_yaml`, and the same
129
+ methods on `Parse::Client`, return a redacted summary, so JSON log
130
+ formatters and error trackers that serialize an agent or client no longer
131
+ emit its session token, master key, or REST key.
132
+ - **FIXED**: A sub-agent that holds its parent's session token on the
133
+ parent's client reuses the parent's resolved scope instead of resolving it again. A failed resolution
134
+ of a different child token is reported as `Parse::Agent::UnresolvedIdentity`,
135
+ not as an attempt to widen the parent's scope.
136
+ - **FIXED**: Lazy session resolution, `impersonate`, and `refresh_scope!` are
137
+ serialized per agent, so a concurrent `impersonate` cannot leave one
138
+ token's scope bound to another token. A failed lazy resolution is retried
139
+ after 5 seconds, not on every call. `refresh_scope!` resolves or raises for
140
+ a session agent instead of returning nil, and an invalid or expired token
141
+ gets its own error message. `acl_scope` checks and reads the scope under
142
+ the same lock, and `acl_permission_strings` never returns nil for a
143
+ session-token agent.
144
+ - **FIXED**: A session agent whose token cannot be resolved records the
145
+ failure at construction too, so the first use inside the 5-second window is
146
+ refused without a second lookup, and a token Parse Server rejected is not
147
+ retried. The lookup runs outside the agent's scope lock, so other threads
148
+ using the agent are not blocked for a request timeout, and the result binds
149
+ only if the token is unchanged. A sub-agent reuses its parent's scope only
150
+ from a token-and-scope snapshot taken under the parent's lock.
151
+ - **FIXED**: `Parse::Client` and `Parse::Agent` refuse `Marshal.dump`
152
+ (`TypeError`), so a client's master key or an agent's session token cannot
153
+ land in a marshaled cache or job payload.
154
+ - **CHANGED**: `Parse::Agent#stop_impersonating!` is a no-op on an agent that
155
+ is not impersonating, instead of dropping a constructor-supplied session
156
+ token and leaving master-key posture.
157
+
158
+ #### Response cache
159
+
160
+ - **FIXED**: Reads made with a session token are no longer served from the
161
+ response cache. A cached answer was returned without contacting Parse
162
+ Server, so a revoked session (logout, `Parse::Session#destroy`,
163
+ `logout_all!`, a password change), or a user who lost a role or row access,
164
+ kept reading the cached rows until the entry expired. Session reads now
165
+ always reach Parse Server, whether the session came from `session_token:`,
166
+ `Parse.with_session`, or a session-bound client. Master-key and anonymous
167
+ reads are cached as before, and writes made with a session still invalidate
168
+ cached entries.
169
+ - **NEW**: `Parse::Middleware::Caching.cache_session_requests = true` (or
170
+ per client, `Parse.setup(cache_session_requests: true)`, which overrides the
171
+ class default either way) opts back into caching session reads, for apps
172
+ that accept revocation being bounded by the cache TTL. The
173
+ `parse.cache.bypass` event includes `cache_tenant`.
174
+
175
+ #### Regex validation and direct reads
176
+
177
+ - **FIXED**: A raw `$regex` in a where hash
178
+ (`Model.query(name: { "$regex" => ... })`, `:field.eq`, `:field.not`,
179
+ `$elemMatch`, and `:or` branches) goes through the same ReDoS check as
180
+ `:field.like`. Before, only the regex constraints themselves were checked.
181
+ Ruby `Regexp` and BSON regex values under `$not`, `$in`, `$nin`, and `$all`
182
+ are checked too.
183
+ - **FIXED**: Mongo-direct filters and pipelines (`results_direct`,
184
+ `count_direct`, `Query#aggregate`, `Parse::MongoDB.aggregate` and `find`,
185
+ Atlas Search and vector filters, LiveQuery `where`, and the agent
186
+ `aggregate` tool) run every `$regex`, Ruby `Regexp`, BSON regex, and
187
+ `$regexMatch` / `$regexFind` / `$regexFindAll` pattern through the ReDoS
188
+ check. Before, only the pattern length was capped. The agent constraint
189
+ translator applies the same check.
190
+ - **FIXED**: `Parse::RegexSecurity` parses each caller-supplied pattern
191
+ instead of matching a few substrings. It refuses a group repeated more than
192
+ once whose body holds a variable-count quantifier (including an optional
193
+ `?`), an alternation, or a backreference (`(a+)+`, `(a|aa)+$`, `((a+))+`,
194
+ `(a+){2,}`, `(a+){20}`, `^(a?){100}a{100}$`), inline comments
195
+ `(?#...)`, extended mode, and patterns it cannot read. Before, several of
196
+ these shapes passed. Repeats of a single atom (`^.{1,255}$`, `\d{1,100}`)
197
+ and simple lookarounds (`^(?!test)`) are accepted; the old checker refused
198
+ them for `:field.like`. Escaped literal patterns
199
+ built by `contains`, `starts_with`, and `ends_with` get a longer length cap,
200
+ so long values keep compiling. It also refuses subroutine calls (`\g<1>`,
201
+ `\g'1'`), more than three unbounded or wide quantifiers in a row with no
202
+ separating literal they cannot match (`\d+\d+\d+\d+`, `.*a.*a.*a.*a`),
203
+ and whitespace inside a repeat count (`{ 2,}`), and reads `[:` that is not a
204
+ complete POSIX class as a literal, as PCRE2 does. A repeated group is
205
+ allowed when each repeat cannot split its input two ways: fixed
206
+ alternatives whose first characters differ even ignoring case
207
+ (`^(foo|bar)+$`, but not `(?i)^(a|Aa)+$`) or a literal
208
+ separator next to one run that cannot match it
209
+ (`^[a-z0-9]+(?:-[a-z0-9]+)*$`, `(\.[\w-]+)+`, `([\w-]+\.)+`).
210
+ - **FIXED**: `$regexMatch`, `$regexFind`, and `$regexFindAll` in
211
+ caller-supplied pipelines require a literal `regex` (a String that is not a
212
+ field path or variable, a Regexp, or a BSON regex). An expression or
213
+ `"$field"` operand raises `Parse::PipelineSecurity::Error`
214
+ (`reason: :regex_not_literal`).
215
+ - **IMPROVED**: Mongo-direct reads (`Parse::MongoDB.aggregate` and `find`,
216
+ `results_direct`, `count_direct`, Atlas Search, and vector and hybrid
217
+ search) whose filter or pipeline carries a regex that is not escaped literal
218
+ text get a server-side `max_time_ms` of
219
+ `Parse::PipelineSecurity.default_regex_max_time_ms` (5000 ms) when the
220
+ caller passed none. Set it to nil to turn this off; an explicit
221
+ `max_time_ms:` always wins. For REST queries, set Parse Server's
222
+ `databaseOptions.maxTimeMS`.
223
+ - **FIXED**: `Parse::MongoDB.find` raises `Parse::ACLScope::ACLRequired` inside
224
+ `Parse.without_master_key` instead of reading raw documents unscoped.
225
+ `Parse::MongoDB.indexes` (metadata only) stays available.
226
+
227
+ #### Webhooks
228
+
229
+ - **FIXED**: A webhook request's `master` flag is trusted only when the
230
+ request is authenticated: the webhook key matched, or a configured
231
+ signature verified. Under `Parse::Webhooks.allow_unauthenticated` with no
232
+ signature, a body claiming `"master": true` no longer passes master-only
233
+ field guards, ACL owner resolution, or handler `payload.master?` checks, and
234
+ no longer triggers the Ruby-initiated callback dedup, so a forged `_RB_`
235
+ request id cannot make a write skip model callbacks (including a
236
+ `before_save` that rejects it). Only a JSON `true` counts as master.
237
+ `payload.authenticated?` reports whether the request was authenticated, and
238
+ `payload.claimed_master?` exposes the raw claim; both are for diagnostics
239
+ only. Signature verification and the authenticated flag come from one read
240
+ of the signing secret.
241
+
242
+ #### Behavior Notes
243
+
244
+ - **Transport.** A LiveQuery `https://` URL is used as `wss://` (and a
245
+ loopback `http://` as `ws://`) with a deprecation warning; configure the
246
+ `ws(s)://` URL directly. Other LiveQuery schemes raise. `HTTP://` with
247
+ `require_https`, and an https server URL with TLS verification disabled
248
+ (`verify: false`, `verify_mode`, `verify_hostname`, or `SSLOptions`), raise.
249
+ Loopback hosts for these checks are `localhost`, `127.0.0.0/8`, `::1`, and
250
+ `0.0.0.0`, parsed as addresses (`127.999.1.1` and `localhost.` are not
251
+ loopback). A padded MCP API key is enforced without its surrounding
252
+ whitespace. Hydrated `Parse::File` URLs with an uppercase scheme or leading
253
+ whitespace are checked against `trusted_url_hosts`; malformed http(s) URLs
254
+ are refused or stripped under `:raise` / `:strip` and warn under `:warn`,
255
+ as are non-http(s) schemes (`javascript:`, `data:`, `file:`) and
256
+ scheme-relative values pointing at an untrusted host.
257
+ - **Printing.** `Parse::Response#to_s` replaces credential-bearing values with
258
+ `"[FILTERED]"` and `#inspect` no longer shows values; read `#result` (or
259
+ `#result.to_json`) for raw data. `to_s` may also filter text in string
260
+ values that looks like a credential (`password=...`). `Parse::Agent` and `Parse::Client`
261
+ `inspect`, `to_s`, `as_json`, `to_json`, and `to_yaml` print redacted
262
+ summaries.
263
+ - **Agents.** A session-token agent that cannot resolve its token refuses
264
+ tool calls that need its permissions. `Agent#acl_scope` can now raise
265
+ `Parse::Agent::UnresolvedIdentity`, and `acl_scope?` is true for any
266
+ session-token agent; custom handlers that test `agent.acl_scope` for
267
+ truthiness should use `agent.acl_scope?`. A sub-agent of an unresolved
268
+ parent can inherit the parent's token, but one with a different identity
269
+ raises until the parent's token resolves. A session agent built while Parse
270
+ Server is unreachable refuses calls for 5 seconds before retrying.
271
+ Marshaling a `Parse::Client` or `Parse::Agent` raises `TypeError`; store the
272
+ configuration and build a new client instead.
273
+ - **Response cache.** Queries that set `cache: true` (or run under
274
+ `Parse.default_query_cache = true`) with a session token no longer get cache
275
+ hits. Set `cache_session_requests` to `true` (only a literal `true`
276
+ enables it) to restore the old behavior, and keep `expires:` short if you
277
+ do.
278
+ - **Regex.** A raw `$regex`, or a `Regexp` in a mongo-direct filter, that the
279
+ ReDoS check rejects now raises: `ArgumentError` on REST queries,
280
+ `Parse::PipelineSecurity::Error` (or `Parse::MongoDB::DeniedOperator`) on
281
+ direct paths. Escaped literal patterns, including those built by
282
+ `starts_with`, `ends_with`, and `contains`, pass the shape check and get a
283
+ length cap about twice the normal one. Caller-supplied `$options` accept
284
+ `i`, `m`, `s`, and `u`; an unknown flag, a non-String value, the extended
285
+ flag `x`, or a Regexp built with `Regexp::EXTENDED` raises. The agent
286
+ translator now accepts `s` and `u` and no longer accepts `x`. A pattern with
287
+ more than three unbounded quantifiers in a row and no literal between them
288
+ (for example `^\w+\s+\w+\s+\w+\s+\w+$`) is refused; add a literal
289
+ separator or split the query. A mongo-direct read carrying a non-literal
290
+ regex and no `max_time_ms` times out after 5 seconds with
291
+ `Parse::MongoDB::ExecutionTimeout`; pass `max_time_ms:` or set
292
+ `Parse::PipelineSecurity.default_regex_max_time_ms` to change it.
293
+ - **Direct reads.** Code that calls `Parse::MongoDB.find` inside
294
+ `Parse.without_master_key` should wrap it in `Parse.with_master_key` or use
295
+ a scoped read. `Parse::MongoDB.collection` returns raw driver access with no
296
+ scope and is not refused inside the block.
297
+ - **Batches.** A batch or transaction of objects whose class is bound to a
298
+ session client now runs as that user, as a single save does. A transaction
299
+ mixing credentials raises `MixedAuthorityError`, and a non-transactional
300
+ batch with mixed credentials is sent as several calls. An explicitly chosen
301
+ batch client (`client=` or the `batch_request` receiver) is used for every
302
+ request instead of each object's class client.
303
+ `Client#batch_request` accepts `session_token:` and `use_master_key:`. A
304
+ blank `session:`, or a user with no session token, raises `ArgumentError`.
305
+ - **Webhooks.** `payload.master?` and `payload.claimed_master?` no longer
306
+ treat the string `"true"` as master; Parse Server always sends a JSON
307
+ boolean. Under `Parse::Webhooks.allow_unauthenticated` with no signing
308
+ secret, `payload.master?` is false even for a master-key request Parse
309
+ Server forwards, so master-only field guards and handler `master?` checks
310
+ refuse it, and saves made by the SDK itself run their model callbacks in
311
+ process and again through the webhook. Configure a webhook key
312
+ (`PARSE_SERVER_WEBHOOK_KEY` on Parse Server and `Parse::Webhooks.key` in the
313
+ app) or a signing secret to restore master trust and dedup.
314
+
3
315
  ### 5.8.1
4
316
 
5
317
  A correctness release for webhooks, sessions, associations, queries, search,
data/README.md CHANGED
@@ -3069,8 +3069,13 @@ songs.save
3069
3069
 
3070
3070
  # you can also destroy a set of objects
3071
3071
  songs.destroy
3072
+
3073
+ # send every write as a specific user (ACL and CLP enforced)
3074
+ songs.save(session: user)
3072
3075
  ```
3073
3076
 
3077
+ Each write in a batch keeps the credentials it was built for. Parse Server runs a `POST /batch` under one credential, so objects whose classes use clients with different credentials (for example a class bound to `Parse.client.become(token)`; identically configured clients are merged), or raw `Parse::Request`s carrying different `session_token:` or `use_master_key:` options, are sent as separate batch calls, each with its own credentials. Responses still come back in request order.
3078
+
3074
3079
  ### Magic `save_all`
3075
3080
  By default, all Parse queries have a maximum fetch limit of 1000. While using the `:max` option, Parse-Stack can increase this up to 11,000. In the cases where you need to update a large number of objects, you can utilize the `Parse::Object#save_all` method to fetch, modify and save objects.
3076
3081
 
@@ -3131,6 +3136,7 @@ end
3131
3136
  - **Mixed operations**: Support create, update, and delete operations in single transaction
3132
3137
  - **Error handling**: Comprehensive error handling with meaningful exception messages
3133
3138
  - **Object ID assignment**: New objects automatically receive their `objectId`, `createdAt`, and `updatedAt` from the server response after successful transaction
3139
+ - **One credential**: A transaction runs with the credentials of its objects' class client, or as a given user with `Parse::Object.transaction(session: user)`. Objects bound to clients with different credentials raise `Parse::BatchOperation::MixedAuthorityError` before anything is sent, because a transaction cannot be split.
3134
3140
 
3135
3141
  ```ruby
3136
3142
  # Transaction with custom retry limit and error handling
data/docs/caching.md CHANGED
@@ -230,6 +230,38 @@ caching path the two never meet. Three points are worth knowing anyway.
230
230
 
231
231
  ## Auth scoping of cached responses
232
232
 
233
+ ### Session reads are not cached by default
234
+
235
+ A read made with a session token is never read from or stored in the response
236
+ cache unless the application opts in:
237
+
238
+ ```ruby
239
+ Parse::Middleware::Caching.cache_session_requests = true # every client
240
+ Parse.setup(cache_session_requests: true, ...) # one client
241
+ ```
242
+
243
+ Only `true` enables it (a `"true"` String from an environment variable does
244
+ not). A client's own `cache_session_requests:` option overrides the class
245
+ default in either direction.
246
+
247
+ A cached answer is served without contacting Parse Server, so it cannot notice
248
+ that the session was revoked (logout, `Parse::Session#destroy`, `logout_all!`,
249
+ a password change) or that the user lost a role or row access through a change
250
+ this process did not make. With the default, every REST session read reaches
251
+ Parse Server, which checks the token and the current ACLs and CLPs each time.
252
+ Mongo-direct reads, including agent tools that route there, do not go through
253
+ this cache; they resolve the session through the identity plane, whose
254
+ revocation behavior is described in
255
+ [Identity and role caching](#identity-and-role-caching). This
256
+ applies however the session reached the request: an explicit `session_token:`,
257
+ `Parse.with_session`, or a client bound to a session. Master-key and
258
+ anonymous reads cache as before, and writes made with a session still
259
+ invalidate cached entries. Turning the option on trades that guarantee for
260
+ speed: a revoked or narrowed session keeps reading its cached responses until
261
+ they expire, so keep `expires:` short if you enable it.
262
+
263
+ ### The auth discriminator
264
+
233
265
  The cache key carries an auth discriminator, and this is a correctness property
234
266
  rather than an optimization.
235
267
 
@@ -251,9 +283,9 @@ URL digest is placed before the discriminator, so every auth variant of one
251
283
  resource shares a prefix, which is exactly what lets a write invalidate the
252
284
  resource for all callers with a single pattern delete.
253
285
 
254
- One consequence worth planning for: a heavily multi-user endpoint produces one
255
- cache entry per session per URL. Cardinality scales with active sessions, not
256
- with distinct resources.
286
+ One consequence worth planning for, once session caching is enabled: a heavily
287
+ multi-user endpoint produces one cache entry per session per URL. Cardinality
288
+ scales with active sessions, not with distinct resources.
257
289
 
258
290
  ## Identity and role caching
259
291
 
@@ -703,8 +735,10 @@ Work down this list.
703
735
  so deliberately.
704
736
  5. Is this a `fetch!` or `reload!`? Those default to write-only mode, which by
705
737
  design never reads from the cache. Use `fetch_cache!` to accept a cached body.
706
- 6. Is the caller a different session? Entries are not shared across auth
707
- identities, so the first request for each session is always a miss.
738
+ 6. Is the read made with a session token? Session reads bypass the cache
739
+ unless `Parse::Middleware::Caching.cache_session_requests = true`. With it
740
+ on, entries are not shared across auth identities, so the first request for
741
+ each session is always a miss.
708
742
  7. Is the store process-local? A Moneta memory store behind several workers hits
709
743
  only when the same worker handles the repeat.
710
744
  8. Has something set `Parse::Middleware::Caching.enabled = false`? That is the
@@ -725,6 +759,7 @@ All events are `ActiveSupport::Notifications`.
725
759
  | `parse.cache.store` | the same, plus `duration_ms` |
726
760
  | `parse.cache.delete` | the same, emitted on an invalidating write |
727
761
  | `parse.cache.error` | the same, plus `error` (the exception class name only) |
762
+ | `parse.cache.bypass` | the same, plus `reason: :session` for a session read kept out of the cache |
728
763
  | `parse.cache.evict` | `pattern_digest`, `deleted`, `duration_ms` |
729
764
  | `parse.synchronize_create.acquired` | `key_digest`, `wait_ms` |
730
765
  | `parse.synchronize_create.contended` | `key_digest`, `elapsed_ms` |
data/docs/mcp_guide.md CHANGED
@@ -1672,7 +1672,12 @@ class Project < Parse::Object
1672
1672
  # Forward the agent's scope to any internal query — pre-filtering by
1673
1673
  # _wperm so the update only sees rows the agent's scope is allowed
1674
1674
  # to modify, defense-in-depth alongside Parse Server's own ACL.
1675
- Audit.all(**agent.acl_scope_kwargs).each { |a| a.cancel! } if agent&.acl_scope
1675
+ # acl_scope? is true for any scoped agent, including a session agent whose
1676
+ # token has not resolved yet (acl_scope itself would raise
1677
+ # Parse::Agent::UnresolvedIdentity for that one); acl_scope_kwargs
1678
+ # forwards the identity, and the scoped read fails closed if it cannot
1679
+ # be resolved.
1680
+ Audit.all(**agent.acl_scope_kwargs).each { |a| a.cancel! } if agent&.acl_scope?
1676
1681
  self.archived = true # property :archived, :boolean
1677
1682
  self.archive_reason = reason # property :archive_reason, :string
1678
1683
  save
@@ -3439,7 +3444,7 @@ Parse::Agent.rack_app(logger: Rails.logger) { |env| ... }
3439
3444
 
3440
3445
  **Sub-agent auth-scope inheritance and permissions clamp (v4.2).** When a tool handler constructs a sub-agent with `Parse::Agent.new(parent: agent, ...)`, the sub inherits `session_token` and `tenant_id` from the parent unless explicitly overridden. Without this inheritance, a session-token parent would silently produce a master-key sub-agent — the constructor default `session_token: nil` resolves to master-key mode — escalating privilege through the very kwarg meant to close sub-agent footguns. Explicit overrides still work (`Parse::Agent.new(parent: agent, session_token: nil)` produces a master-key sub if that is genuinely what the handler wants), but the default is fail-safe inheritance. `permissions:` is NOT inherited and defaults to `:readonly`, but the constructor enforces a clamp: an explicit `permissions:` override on a sub-agent is accepted only if `≤ parent.permissions`, otherwise `ArgumentError` is raised at construction. The clamp is the structural guarantee that a delegation chain cannot escape the parent's tier through sub-agent construction. See [Per-Agent Tool Filtering & Sub-Agent Delegation](#per-agent-tool-filtering--sub-agent-delegation-v42) for the full inheritance table.
3441
3446
 
3442
- **Agent-level ACL scope: `session_token:` / `acl_user:` / `acl_role:` (v4.4.0).** `Parse::Agent.new` accepts three mutually-exclusive identity inputs. `session_token:` round-trips Parse Server's `/users/me` at construction (or defers to per-call REST if the server is unreachable). `acl_user:` takes a `Parse::User` or User-pointer and expands the user's role subscription via `Parse::Role.all_for_user` — no token round-trip, the SDK enforces the resulting `_rperm` filter itself. `acl_role:` is service-account-style scoping — no user_id, just the role plus parent-role inheritance. Master-key posture (none of the three supplied) remains the default and still emits the one-time `[Parse::Agent:SECURITY]` banner at construction. Every built-in tool reads `agent.acl_scope_kwargs` (single point of truth) to forward identity into `Parse::MongoDB.aggregate`, `Parse::Query#results_direct`, and `Parse::AtlasSearch.{search,autocomplete}`. Developer-registered tool handlers and `agent_method` bodies can reach `agent.acl_scope`, `agent.acl_permission_strings`, `agent.acl_read_match_stage` (a `_rperm` `$match`), or `agent.acl_write_match_stage` (a `_wperm` `$match`) to apply the agent's identity to their own queries.
3447
+ **Agent-level ACL scope: `session_token:` / `acl_user:` / `acl_role:` (v4.4.0).** `Parse::Agent.new` accepts three mutually-exclusive identity inputs. `session_token:` round-trips Parse Server's `/users/me` at construction. If the server is unreachable then, the agent retries on the first tool call that needs its permissions; if that also fails, the call raises `Parse::Agent::UnresolvedIdentity` (reported as `:access_denied`, `kind: :unresolved_identity`) instead of running without the SDK-side check, and further calls within the next few seconds are refused without another lookup (5.8.2). `acl_user:` takes a `Parse::User` or User-pointer and expands the user's role subscription via `Parse::Role.all_for_user`. It makes no token round-trip, and the SDK enforces the resulting `_rperm` filter itself. `acl_role:` is service-account-style scoping: no user_id, just the role plus parent-role inheritance. Master-key posture (none of the three supplied) remains the default and still emits the one-time `[Parse::Agent:SECURITY]` banner at construction. Every built-in tool reads `agent.acl_scope_kwargs` (single point of truth) to forward identity into `Parse::MongoDB.aggregate`, `Parse::Query#results_direct`, and `Parse::AtlasSearch.{search,autocomplete}`. Developer-registered tool handlers and `agent_method` bodies can reach `agent.acl_scope`, `agent.acl_permission_strings`, `agent.acl_read_match_stage` (a `_rperm` `$match`), or `agent.acl_write_match_stage` (a `_wperm` `$match`) to apply the agent's identity to their own queries.
3443
3448
 
3444
3449
  **ACL composition on the mongo-direct aggregate path (v4.4.0).** When `aggregate` routes through `Parse::MongoDB.aggregate` (the default when `Parse::MongoDB.enabled?` is true), the agent layer derives the auth posture from the agent instance and forwards it to ACLScope — session-tokened / acl_user / acl_role agents get the same row-level `_rperm` `$match` injection regardless of identity mode; master-key agents pass `master: true` (the agent's class/field/tenant/canonical-filter gates are the security boundary for that posture). The posture is built in `Parse::Agent#acl_scope_kwargs`, not from tool-call JSON arguments; LLM-supplied `master:`, `session_token:`, `acl_user:`, or `acl_role:` kwargs are silently swallowed by the tool signature's `**_kwargs` catchall and never reach `Parse::MongoDB.aggregate`. An LLM cannot escalate from a scoped posture to master-key by injecting `master: true` into the tool arguments.
3445
3450
 
@@ -287,6 +287,9 @@ raw = Parse::MongoDB.find(
287
287
  Convenience wrapper around `db.find`. Accepts `limit:`, `skip:`, `sort:`,
288
288
  `projection:`, `hint:`, `max_time_ms:`. When `:limit` is omitted the call applies
289
289
  `DEFAULT_FIND_LIMIT = 1000` and warns; pass `limit: 0` to opt out.
290
+ `find` reads raw documents unscoped, so it raises `Parse::ACLScope::ACLRequired`
291
+ inside `Parse.without_master_key`; `Parse::MongoDB.indexes` (metadata only) is
292
+ allowed there.
290
293
 
291
294
  ### Forcing an index with `hint`
292
295
 
@@ -761,6 +764,32 @@ legitimate read stages — but they read from arbitrary collections. Never
761
764
  pass attacker-controlled input into a pipeline; build the pipeline in
762
765
  trusted code and interpolate only validated values.
763
766
 
767
+ #### Regex patterns and the default time limit
768
+
769
+ Every caller-supplied regex on these paths (`$regex` strings, Ruby
770
+ `Regexp` and BSON regex values, and the `regex` operand of `$regexMatch`,
771
+ `$regexFind`, and `$regexFindAll`) goes through `Parse::RegexSecurity`,
772
+ which parses the pattern and refuses shapes that backtrack heavily on
773
+ PCRE: nested or overlapping repeats such as `(a+)+` or `(a|aa)+`, more
774
+ than three unbounded quantifiers in a row with no separating literal
775
+ (`\d+\d+\d+\d+`), subroutine calls (`\g<1>`), inline comments, and
776
+ extended mode. Escaped literal text, such as the patterns `starts_with`,
777
+ `ends_with`, and `contains` build, always passes the shape check.
778
+
779
+ A pattern check cannot prove every regex is cheap, so mongo-direct reads
780
+ whose filter or pipeline carries a regex that is not escaped literal text
781
+ also get a server-side `max_time_ms` of
782
+ `Parse::PipelineSecurity.default_regex_max_time_ms` (5000 ms by default)
783
+ when the caller passed none. This covers `Parse::MongoDB.aggregate` and
784
+ `find`, `results_direct`, `count_direct`, Atlas Search, and vector and
785
+ hybrid search. Set the attribute to a different value, or to `nil` to
786
+ turn the default off; an explicit `max_time_ms:` always wins. A query that
787
+ runs past the limit raises `Parse::MongoDB::ExecutionTimeout`.
788
+
789
+ REST queries are executed by Parse Server, which applies no regex check of
790
+ its own. Set `databaseOptions.maxTimeMS` in the Parse Server configuration
791
+ to bound those.
792
+
764
793
  ### Layer 2: Row-level ACL enforcement (`Parse::ACLScope`) — scoped only
765
794
 
766
795
  When `Parse::MongoDB.aggregate` is called with `session_token:`,
@@ -210,6 +210,36 @@ run Parse::Webhooks
210
210
  See [`examples/webhook_server.rb`](../examples/webhook_server.rb) for a complete,
211
211
  runnable setup.
212
212
 
213
+ ### Authenticating requests, and what `master?` means
214
+
215
+ Requests are accepted only when `X-Parse-Webhook-Key` matches
216
+ `Parse::Webhooks.key` (`PARSE_SERVER_WEBHOOK_KEY`). With no key configured the
217
+ Rack app refuses every request unless you opt in with
218
+ `Parse::Webhooks.allow_unauthenticated = true` (or
219
+ `PARSE_WEBHOOK_ALLOW_UNAUTHENTICATED=true`), which is meant for local
220
+ development and tests. A signing secret does not replace the key: with no key
221
+ configured, a signed request is still refused unless `allow_unauthenticated`
222
+ is on. When both are configured, the key must match and the signature must
223
+ verify.
224
+
225
+ `payload.master?` is true only when the body says the master key was used (a
226
+ JSON `true`; Parse Server never sends a string) AND
227
+ the request was authenticated: the webhook key matched, or a configured
228
+ signature (`PARSE_WEBHOOK_SIGNING_SECRET`) verified. On unauthenticated ingress
229
+ any caller can put `"master": true` in the body, so `master?` returns false
230
+ there. Master-only field guards, ACL owner resolution, and handler checks
231
+ built on `master?` therefore treat such a request as a normal client request.
232
+ The Ruby-initiated callback dedup uses `master?` too, so a forged `_RB_`
233
+ request id plus `"master": true` cannot make a request skip model callbacks
234
+ (including a `before_save` that would reject it). `payload.claimed_master?`
235
+ returns the body's raw claim and `payload.authenticated?` reports whether the
236
+ request was authenticated; both are for diagnostics only.
237
+
238
+ The trade-off: on unauthenticated ingress, a save made by the SDK itself is
239
+ not recognized as Ruby-initiated, so its model callbacks can run twice (once
240
+ in process, once through the webhook). Configure a webhook key or a signing
241
+ secret to keep the dedup.
242
+
213
243
  ## Auditing trigger coverage
214
244
 
215
245
  The wiring above has three independent moving parts, and a callback runs
@@ -486,8 +516,11 @@ master key). It has already run the model's `before_save` / `after_save` /
486
516
  The intent is to keep trigger logic local when possible and run it exactly once.
487
517
  Note that any logic in the **webhook block itself** still runs; only the
488
518
  duplicate ActiveModel callback pass is skipped. A spoofed `_RB_` marker without
489
- the master key does not get this treatment — the callbacks run in the webhook as
490
- usual.
519
+ the master key does not get this treatment, and neither does a claimed master
520
+ key on an unauthenticated request (no webhook key match and no verified
521
+ signature): the callbacks run in the webhook as usual. On unauthenticated
522
+ ingress this means SDK-initiated saves run their callbacks twice; configure a
523
+ webhook key or signing secret to keep the dedup.
491
524
 
492
525
  ### 2. Server-initiated replay / freshness protection (inbound)
493
526
 
@@ -117,17 +117,16 @@ module Parse
117
117
  # worst-case backtracking cost on any one pattern bounded.
118
118
  MAX_REGEX_PATTERN_LENGTH = 256
119
119
 
120
- # Allowed $options flag characters. MongoDB accepts i (case
121
- # insensitive), m (multi-line), x (extended/whitespace-ignored),
122
- # s (dot-all). The dot-all `s` flag is intentionally omitted: it
123
- # makes `.` cross newlines, which extends the search frontier on
124
- # multi-line text fields and amplifies catastrophic-backtracking
125
- # cost for the worst patterns. `imx` covers every real use case
126
- # the agent surface needs.
127
- ALLOWED_REGEX_OPTIONS = "imx"
128
-
129
- # Heuristic for nested-quantifier ReDoS patterns (catastrophic
130
- # backtracking). Matches a quantifier (`+` or `*`) INSIDE a
120
+ # Allowed $options flag characters: the shared
121
+ # {Parse::RegexSecurity::ALLOWED_OPTIONS} list (`imsu`). The extended
122
+ # flag `x` is refused because it lets comments hide a quantifier from
123
+ # the pattern check.
124
+ ALLOWED_REGEX_OPTIONS = defined?(Parse::RegexSecurity::ALLOWED_OPTIONS) ? Parse::RegexSecurity::ALLOWED_OPTIONS : "imsu"
125
+
126
+ # Older heuristic for nested-quantifier ReDoS patterns, kept for
127
+ # callers that reference it. Patterns are now checked by
128
+ # {Parse::RegexSecurity.validate!}, which parses the pattern. This
129
+ # heuristic matches a quantifier (`+` or `*`) INSIDE a
131
130
  # parenthesized group that is itself followed by a quantifier
132
131
  # (`+`, `*`, or `?`) — the structural shape that drives
133
132
  # exponential time on adversarial inputs (`(a+)+`, `(a*)*`,
@@ -435,15 +434,17 @@ module Parse
435
434
  #
436
435
  # 1. $regex must be a String. No Hash/Array/Numeric values.
437
436
  # 2. Pattern length ≤ MAX_REGEX_PATTERN_LENGTH (256 chars).
438
- # 3. Pattern must not match the nested-quantifier heuristic
439
- # (REDOS_NESTED_QUANTIFIER_RE).
437
+ # 3. Pattern must pass Parse::RegexSecurity.validate!, which
438
+ # parses it and refuses repeated groups holding a quantifier,
439
+ # alternation, or backreference, inline comments, and extended
440
+ # mode.
440
441
  #
441
442
  # For $options:
442
443
  #
443
444
  # 1. Must be a String.
444
445
  # 2. Length ≤ 8 (defensive — real-world usage is 0-3 chars).
445
- # 3. Every character must appear in ALLOWED_REGEX_OPTIONS (imx).
446
- # The `s` (dot-all) flag is intentionally rejected.
446
+ # 3. Every character must appear in ALLOWED_REGEX_OPTIONS (imsu).
447
+ # The extended `x` flag is rejected.
447
448
  #
448
449
  # @raise [ConstraintSecurityError] on any rule violation.
449
450
  def assert_regex_operand_safe!(op, val)
@@ -465,12 +466,12 @@ module Parse
465
466
  reason: :regex_too_long,
466
467
  )
467
468
  end
468
- if REDOS_NESTED_QUANTIFIER_RE.match?(val)
469
+ unless Parse::RegexSecurity.safe?(val, max_length: MAX_REGEX_PATTERN_LENGTH)
469
470
  raise ConstraintSecurityError.new(
470
471
  "$regex pattern #{val.inspect} contains a nested quantifier " \
471
- "(`(...x+...)+` shape) that can trigger catastrophic " \
472
- "backtracking on MongoDB's PCRE engine. Rewrite the pattern " \
473
- "without nested quantifier groups.",
472
+ "or another backtracking-prone construct that can trigger " \
473
+ "catastrophic backtracking on MongoDB's PCRE engine. Rewrite " \
474
+ "the pattern without nested quantifier groups.",
474
475
  operator: op,
475
476
  reason: :regex_redos,
476
477
  )
@@ -495,8 +496,8 @@ module Parse
495
496
  raise ConstraintSecurityError.new(
496
497
  "$options contains disallowed flag(s) " \
497
498
  "#{unrecognized.uniq.inspect}. Allowed flags: " \
498
- "#{ALLOWED_REGEX_OPTIONS.chars.inspect}. The dot-all " \
499
- "`s` flag is intentionally rejected.",
499
+ "#{ALLOWED_REGEX_OPTIONS.chars.inspect}. The extended " \
500
+ "`x` flag is rejected.",
500
501
  operator: op,
501
502
  reason: :invalid_regex,
502
503
  )