parse-stack-next 5.7.6 → 5.8.1

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.
Files changed (106) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1148 -0
  3. data/README.md +39 -23
  4. data/docs/TEST_SERVER.md +2 -2
  5. data/docs/acl_clp_guide.md +7 -0
  6. data/docs/atlas_vector_search_guide.md +181 -13
  7. data/docs/caching.md +50 -0
  8. data/docs/client_sdk_guide.md +11 -0
  9. data/docs/mcp_guide.md +332 -9
  10. data/docs/mongodb_direct_guide.md +27 -0
  11. data/docs/usage_guide.md +38 -0
  12. data/docs/webhooks_guide.md +126 -17
  13. data/lib/parse/acl_scope.rb +230 -50
  14. data/lib/parse/agent/approval_gate.rb +0 -0
  15. data/lib/parse/agent/constraint_translator.rb +42 -15
  16. data/lib/parse/agent/describe.rb +11 -3
  17. data/lib/parse/agent/field_names.rb +53 -0
  18. data/lib/parse/agent/field_policy.rb +74 -0
  19. data/lib/parse/agent/mcp_deployments.rb +426 -0
  20. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  21. data/lib/parse/agent/mcp_rack_app.rb +424 -45
  22. data/lib/parse/agent/mcp_server.rb +23 -1
  23. data/lib/parse/agent/mcp_subscriptions.rb +124 -6
  24. data/lib/parse/agent/metadata_registry.rb +67 -8
  25. data/lib/parse/agent/prompt_hardening.rb +9 -3
  26. data/lib/parse/agent/tools.rb +378 -29
  27. data/lib/parse/agent.rb +298 -21
  28. data/lib/parse/api/batch.rb +10 -1
  29. data/lib/parse/api/schema.rb +23 -4
  30. data/lib/parse/api/sessions.rb +6 -2
  31. data/lib/parse/api/users.rb +88 -14
  32. data/lib/parse/atlas_search/index_manager.rb +5 -1
  33. data/lib/parse/atlas_search/protected_paths.rb +236 -0
  34. data/lib/parse/atlas_search.rb +144 -24
  35. data/lib/parse/authorization.rb +289 -4
  36. data/lib/parse/cache/sub_cache.rb +26 -0
  37. data/lib/parse/client/authentication.rb +19 -1
  38. data/lib/parse/client/batch.rb +264 -35
  39. data/lib/parse/client/body_builder.rb +21 -0
  40. data/lib/parse/client/caching.rb +371 -27
  41. data/lib/parse/client/request.rb +26 -14
  42. data/lib/parse/client/response.rb +49 -6
  43. data/lib/parse/client.rb +227 -40
  44. data/lib/parse/clp_scope.rb +317 -25
  45. data/lib/parse/console.rb +2 -2
  46. data/lib/parse/embeddings/voyage.rb +181 -17
  47. data/lib/parse/graphql/type_generator.rb +3 -0
  48. data/lib/parse/live_query/client.rb +72 -2
  49. data/lib/parse/lock_backend.rb +4 -3
  50. data/lib/parse/model/acl.rb +119 -21
  51. data/lib/parse/model/associations/belongs_to.rb +25 -3
  52. data/lib/parse/model/associations/collection_proxy.rb +146 -17
  53. data/lib/parse/model/associations/has_many.rb +49 -9
  54. data/lib/parse/model/associations/has_one.rb +3 -1
  55. data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
  56. data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
  57. data/lib/parse/model/bytes.rb +13 -5
  58. data/lib/parse/model/classes/role.rb +95 -0
  59. data/lib/parse/model/classes/session.rb +304 -1
  60. data/lib/parse/model/classes/user.rb +104 -8
  61. data/lib/parse/model/core/actions.rb +287 -70
  62. data/lib/parse/model/core/builder.rb +100 -8
  63. data/lib/parse/model/core/create_lock.rb +27 -2
  64. data/lib/parse/model/core/describe.rb +2 -0
  65. data/lib/parse/model/core/fetching.rb +50 -5
  66. data/lib/parse/model/core/field_guards.rb +16 -8
  67. data/lib/parse/model/core/pluralized_aliases.rb +8 -4
  68. data/lib/parse/model/core/properties.rb +488 -39
  69. data/lib/parse/model/core/querying.rb +7 -0
  70. data/lib/parse/model/core/schema.rb +5 -3
  71. data/lib/parse/model/core/search_indexing.rb +63 -0
  72. data/lib/parse/model/core/vector_searchable.rb +35 -6
  73. data/lib/parse/model/file.rb +9 -2
  74. data/lib/parse/model/geopoint.rb +61 -13
  75. data/lib/parse/model/model.rb +160 -9
  76. data/lib/parse/model/object.rb +317 -18
  77. data/lib/parse/model/phone.rb +54 -5
  78. data/lib/parse/model/pointer.rb +40 -6
  79. data/lib/parse/model/push.rb +11 -1
  80. data/lib/parse/mongodb.rb +189 -61
  81. data/lib/parse/pipeline_security.rb +415 -26
  82. data/lib/parse/query/constraint.rb +31 -1
  83. data/lib/parse/query/constraints.rb +89 -59
  84. data/lib/parse/query/cursor.rb +3 -1
  85. data/lib/parse/query/operation.rb +62 -8
  86. data/lib/parse/query/ordering.rb +34 -6
  87. data/lib/parse/query.rb +1871 -167
  88. data/lib/parse/retrieval/agent_tool.rb +225 -8
  89. data/lib/parse/retrieval/benchmark.rb +149 -0
  90. data/lib/parse/retrieval/profiles.rb +320 -0
  91. data/lib/parse/retrieval/retriever.rb +10 -1
  92. data/lib/parse/retrieval.rb +2 -0
  93. data/lib/parse/schema/search_index_migrator.rb +23 -5
  94. data/lib/parse/schema.rb +74 -18
  95. data/lib/parse/stack/tasks.rb +6 -4
  96. data/lib/parse/stack/version.rb +1 -1
  97. data/lib/parse/stack.rb +91 -14
  98. data/lib/parse/two_factor_auth/user_extension.rb +14 -2
  99. data/lib/parse/two_factor_auth.rb +11 -0
  100. data/lib/parse/vector_search/hybrid.rb +110 -33
  101. data/lib/parse/vector_search/index_definition.rb +237 -0
  102. data/lib/parse/vector_search.rb +259 -23
  103. data/lib/parse/webhooks/payload.rb +136 -6
  104. data/lib/parse/webhooks/replay_protection.rb +58 -20
  105. data/lib/parse/webhooks.rb +715 -40
  106. metadata +8 -1
@@ -87,10 +87,18 @@ one-to-one — the SDK maps between them.
87
87
  - **`beforeFind` / `afterFind` are result-side, not object-side.** Unlike the
88
88
  save/delete triggers, a find payload carries no single `object` — `beforeFind`
89
89
  exposes the incoming `query` (via `payload.query`) and `afterFind` exposes the
90
- matched rows (via `payload.objects`). And unlike `afterSave` (whose return
91
- value Parse Server ignores), **`afterFind` is result-rewriting**: whatever the
92
- handler returns *replaces* the rows sent to the client, so it can filter or
93
- redact results. It also adds a webhook round-trip to every matching query, so
90
+ matched rows (via `payload.objects`). **Over an HTTP webhook, `afterFind` can
91
+ observe the rows or deny the query, but it cannot rewrite them.** Parse Server
92
+ passes every row a webhook returns through `toJSONwithObjects`, which turns
93
+ any plain JSON object into `{}`, so returned rows would reach the client
94
+ blank. The SDK therefore always replies "keep the rows" (`{}`, with no
95
+ `success` key). Returning `nil`, `true`, or the unchanged `payload.objects`
96
+ all pass the rows through. Returning a different set of rows (dropping or
97
+ adding some) denies the query with an error instead, so rows a handler meant
98
+ to hide are never returned. To restrict rows, constrain the query in
99
+ `beforeFind` or with ACLs/CLPs; to block a query, call `error!` or return
100
+ `false`. (In-process `Parse.Cloud.afterFind` cloud code is not subject to this
101
+ limit.) It also adds a webhook round-trip to every matching query, so
94
102
  register it deliberately.
95
103
 
96
104
  One non-obvious detail the SDK handles for you: **Parse Server does not put the
@@ -104,11 +112,9 @@ one-to-one — the SDK maps between them.
104
112
  Because the class is resolved from the route, declared `:vector` columns are
105
113
  stripped from `afterFind` `payload.objects` by default, exactly as they are
106
114
  from `object`/`original`/`update` on the other triggers (a
107
- `vector_visibility :public` class keeps them). One consequence to keep in
108
- mind: an `afterFind` handler that returns `payload.objects` to pass results
109
- through passes the *vector-scrubbed* rows on to the client — which matches the
110
- `as_json` default (an `owner_only` class never exposes vectors anyway). Return
111
- your own array if you need different columns.
115
+ `vector_visibility :public` class keeps them). This affects only what the
116
+ handler sees. The rows the client receives are the ones Parse Server matched,
117
+ unchanged by the webhook.
112
118
 
113
119
  - **Auth triggers (`beforeLogin` / `afterLogin` / `afterLogout` /
114
120
  `beforePasswordResetRequest`) and LiveQuery triggers (`beforeConnect` /
@@ -259,10 +265,102 @@ abort "Webhook coverage gaps detected" if inert.positive?
259
265
  ## Returning a value from a handler
260
266
 
261
267
  A handler block runs with `self` bound to the `Parse::Webhooks::Payload`, so
262
- inside it you can call `parse_object`, `params`, `error!`, etc. directly. The
263
- value the handler produces is what Parse Server receives: for `before_save`,
264
- return the (possibly mutated) `parse_object` to allow the write, or `false` /
265
- `error!` to reject it.
268
+ inside it you can call `parse_object`, `params`, `error!`, etc. directly. What
269
+ the handler returns decides the reply Parse Server receives:
270
+
271
+ | Trigger | Allow | Change | Reject |
272
+ |---------|-------|--------|--------|
273
+ | `before_save` | `true`, `nil`, or an unchanged `parse_object` | the mutated `parse_object`, or a Hash of field overrides | `false` or `error!` |
274
+ | `before_delete` | anything else | n/a | `false`, `error!`, or a `before_destroy` callback that returns `false` |
275
+ | `after_find` | `nil`, `true`, or the unchanged `payload.objects` | not possible over HTTP (see above) | `false` or `error!` |
276
+ | function | the return value is the function result | | `error!` |
277
+
278
+ How a `before_save` change reaches Parse Server: Parse Server *replaces* the
279
+ pending write with the object a beforeSave webhook returns. The SDK therefore
280
+ never replies with only your changes. When nothing changed, it replies "keep
281
+ the write as sent", so the client's atomic operators (`Increment`, `Add`,
282
+ `Remove`, `Delete`, relation operators) and any undeclared fields reach the
283
+ database untouched. When the handler changed fields, the reply is the client's
284
+ full write with your changes layered on top: operators on fields you did not
285
+ touch are passed through as operators, fields the model does not declare (and
286
+ `_User` signup fields such as `password` and `authData`) are kept, and a field
287
+ you set back to its stored value is dropped from the write. A field you
288
+ rewrite is written as an absolute value. Edits made in place on `parse_object`
289
+ count even if the handler returns `true`.
290
+
291
+ An operator on a dotted sub-key (`"meta.count"`) arrives only as its resulting
292
+ sub-document, so on an update the SDK writes back just the sub-keys that
293
+ changed, as dotted paths, and a removed sub-key as a `Delete`. The split is one
294
+ level deep: a changed sub-key is written whole at `field.sub`, nested objects
295
+ included, because Parse Server rebuilds the afterSave object from a dotted key
296
+ only one level deep. Concurrent writes to other sub-keys of the field survive.
297
+ Some limits remain:
298
+
299
+ - Two concurrent writes inside the same sub-key can still overwrite each
300
+ other, including two operators on deeper paths under it
301
+ (`meta.count.value` and `meta.count.other`).
302
+ - An array is written whole, so a concurrent `Add` to an array inside a
303
+ sub-document is lost.
304
+ - When a changed sub-key would carry a typed value (a Date, Bytes, Pointer, or
305
+ other `__type` value), the whole field is written instead, because Parse
306
+ Server stores a dotted typed value without converting it. Unchanged typed
307
+ values and deletes do not trigger this.
308
+ - A client that replaces a whole sub-document gets a merge: the changed
309
+ sub-keys are written and the sub-keys it knew about and removed are
310
+ deleted, so a sub-key another request added in the meantime survives.
311
+ - Parse Server reports the reply's dotted keys back to the client in the save
312
+ response. The JavaScript SDK applies them; other clients may need to fetch
313
+ the object to see the merged value.
314
+ - A dotted key in a Hash the handler returns (`{ "meta.y" => 5 }`) is folded
315
+ into the parent path the reply writes whole. A key deeper than one level
316
+ (`"meta.count.value"`) is folded into a `meta.count` write built from the
317
+ pending object, and on a create every dotted key folds into its whole
318
+ field. An operator folded this way (`{"__op" => "Increment"}`, `Add`,
319
+ `AddUnique`, `Remove`, `Delete`) is applied to the value it replaces; an
320
+ operator that cannot be applied there, or two of your own keys that
321
+ conflict (`"meta.a" => 5` with `"meta.a.b" => 6`), fails the save with an
322
+ error. Hash keys may use Ruby names (`acl:`); they are mapped to the
323
+ stored field names.
324
+
325
+ On a create, returning `parse_object` also writes the model's declared
326
+ defaults for fields the client did not send, including its ACL policy. Parse
327
+ Server stores a create without an `ACL` as public read and write, so the reply
328
+ always carries the ACL your policy resolves, exactly as an SDK-side create
329
+ would. Owner-based policies (`:owner_else_private`, the default, as well as
330
+ `:owner_else_public` and `:owner_but_public_read`) take the owner from the
331
+ declared `owner:` field and, when that is empty, from the user who made the
332
+ request, so a signed-in client owns what it creates. A request without a user
333
+ gets the policy's fallback (master-key only under `:owner_else_private`), and so
334
+ does a master-key request. A `_User` class declared with `owner: :self` is
335
+ owned by the new user, never by the requester: the reply carries the policy's
336
+ ACL without an owner entry and Parse Server adds the new user's own read and
337
+ write. An ACL the client sent is kept, and an ACL the handler sets always
338
+ wins, whether through `acl=` or mass assignment. A `guard :acl, :master_only`
339
+ revert on a create returns the ACL to the class policy rather than to `{}`.
340
+ Built-in classes such as `_User` that have no policy of their own keep Parse
341
+ Server's defaults. A handler that returns `true`, `nil`, or a Hash keeps the client's
342
+ write as sent, so a create without an ACL stays public; return
343
+ `parse_object` to apply the policy. An ACL the handler assigns on
344
+ `parse_object` (`parse_object.acl = Parse::ACL.new`) is written whatever the
345
+ handler returns, unless a returned Hash sets `ACL` itself.
346
+
347
+ `after_destroy` callbacks run in the `after_delete` handler (once per delivery,
348
+ skipped for deletes the SDK itself made, whose callbacks already ran locally).
349
+ `before_destroy` callbacks run in `before_delete` when the handler returns
350
+ `parse_object`.
351
+
352
+ `error!(message, code: 137)` attaches a Parse error code to the error reply
353
+ and to the raised `Parse::Webhooks::ResponseError#code`. Parse Server's HTTP
354
+ webhook adapter currently reports every webhook error to the client as code
355
+ 141 (`SCRIPT_FAILED`), so the code is mainly useful to in-process callers such
356
+ as `Parse::Webhooks.run_function`. Any other exception a handler raises is
357
+ answered with a generic `{"error": "Webhook handler failed."}` reply (the
358
+ exception class and a redacted message are logged), so internal details never
359
+ reach the client.
360
+
361
+ A request for a function with no registered handler is answered with an
362
+ error. A trigger with no registered handler passes the operation through
363
+ unchanged.
266
364
 
267
365
  You can set that value either with an explicit `return` or by letting it be the
268
366
  block's last expression — both work:
@@ -396,10 +494,14 @@ usual.
396
494
  This protects the webhook endpoint against **replayed inbound POSTs** —
397
495
  `lib/parse/webhooks/replay_protection.rb`:
398
496
 
399
- - **Always-on body + request-id dedup.** A bounded LRU records a digest of each
400
- `(request_id, body)`; a duplicate seen within `replay_window_seconds` is
401
- rejected with `"Webhook replay detected."`. No cooperation from Parse Server is
402
- required; this stops in-window replays.
497
+ - **Nonce-keyed dedup.** When a delivery carries an `X-Parse-Request-Id` or
498
+ `X-Parse-Webhook-Nonce` header, a bounded LRU records a digest of
499
+ `(nonce, body)`; a duplicate seen within `replay_window_seconds` is rejected
500
+ with `"Webhook replay detected."`. Parse Server sends neither header on its
501
+ own deliveries, so add one in the proxy or wrapper that delivers webhooks if
502
+ you want dedup. Without a nonce the SDK does not dedup on the body alone:
503
+ two legitimate identical requests (the same function called twice with the
504
+ same params) cannot be told apart from a replay that way.
403
505
  - **Opt-in HMAC freshness verification.** Set a `signing_secret` and the receiver
404
506
  verifies two headers:
405
507
  - `X-Parse-Webhook-Timestamp` — Unix epoch seconds; requests outside
@@ -407,6 +509,13 @@ This protects the webhook endpoint against **replayed inbound POSTs** —
407
509
  - `X-Parse-Webhook-Signature` — hex HMAC-SHA256 of `"#{timestamp}.#{body}"`
408
510
  keyed with the signing secret.
409
511
 
512
+ A signed delivery is deduplicated on its signature, so a captured request
513
+ cannot be replayed inside the skew window, even with an altered or missing
514
+ nonce. To send identical bodies more than once in the same second, include
515
+ an `X-Parse-Webhook-Nonce` header and sign
516
+ `"#{timestamp}.#{nonce}.#{body}"` instead; each delivery then has its own
517
+ signature, and a signature cannot be reused under another nonce.
518
+
410
519
  ```ruby
411
520
  Parse::Webhooks::ReplayProtection.signing_secret = ENV["PARSE_WEBHOOK_SIGNING_SECRET"]
412
521
  Parse::Webhooks::ReplayProtection.replay_window_seconds = 120
@@ -74,13 +74,32 @@ module Parse
74
74
  # agree; see {Parse::MongoDB.verify_client!}. Per-client
75
75
  # authorization plus a process-global MongoDB connection is safe only
76
76
  # when they belong to the same application.
77
- Resolution = Struct.new(:mode, :permission_strings, :user_id, :session, :strict_role, :client, keyword_init: true) do
77
+ # @!attribute master_dropped
78
+ # @return [Boolean, nil] `true` when the caller asked for `master: true`
79
+ # inside {Parse.without_master_key} and the call was downgraded to
80
+ # the public scope. Reported on the `parse.mongodb.aggregate`
81
+ # notification so a master-key caller that sees only public rows
82
+ # inside the block can be diagnosed.
83
+ Resolution = Struct.new(:mode, :permission_strings, :user_id, :session, :strict_role, :client,
84
+ :master_dropped, keyword_init: true) do
78
85
  def master?; mode == :master; end
79
86
  def session?; mode == :session; end
80
87
  def public?; mode == :public; end
81
88
  def strict_role?; strict_role == true; end
89
+ def master_dropped?; master_dropped == true; end
82
90
  end
83
91
 
92
+ # SDK-internal value for `master:` on calls that read metadata, not rows
93
+ # (index statistics, the Atlas Search index listing). It resolves to
94
+ # master mode even inside {Parse.without_master_key}, which governs row
95
+ # access. Unlike wrapping the call in {Parse.with_master_key}, it changes
96
+ # no fiber state, so notification subscribers and threads started during
97
+ # the call still see the caller's block.
98
+ # @!visibility private
99
+ METADATA_MASTER = Object.new.tap do |o|
100
+ def o.inspect = "#<Parse::ACLScope::METADATA_MASTER>"
101
+ end.freeze
102
+
84
103
  # The client a resolution was produced by, or nil when it cannot say.
85
104
  #
86
105
  # Deliberately tolerant of objects that are resolution-shaped without
@@ -125,6 +144,12 @@ module Parse
125
144
  # `master: true` are supplied — they are mutually exclusive.
126
145
  # @raise [ACLRequired] when neither is supplied and
127
146
  # {.require_session_token} is `true`.
147
+ #
148
+ # Inside a {Parse.without_master_key} block an explicit `master: true`
149
+ # is dropped and the call runs in the public scope, as a REST request
150
+ # does once the block strips its master key. With
151
+ # {.require_session_token} set, the dropped call raises {ACLRequired}
152
+ # instead. A nested {Parse.with_master_key} block restores master mode.
128
153
  def resolve!(options, method_name:)
129
154
  session_token = options.delete(:session_token)
130
155
  master = options.delete(:master)
@@ -140,6 +165,10 @@ module Parse
140
165
  # the legacy behavior.
141
166
  strict_role = options.delete(:strict_role) == true
142
167
 
168
+ # SDK metadata reads keep master mode whatever the block says.
169
+ metadata_master = master.equal?(METADATA_MASTER)
170
+ master = true if metadata_master
171
+
143
172
  provided = [session_token, master == true ? master : nil, acl_user, acl_role].compact
144
173
  if provided.length > 1
145
174
  raise ArgumentError,
@@ -147,6 +176,12 @@ module Parse
147
176
  "session_token:, master: true, acl_user:, or acl_role:. Pick one."
148
177
  end
149
178
 
179
+ # `Parse.without_master_key` strips the master key from every REST
180
+ # request in the block, an explicit `use_master_key: true` included.
181
+ # A direct read must not keep master authority the REST call loses.
182
+ master_dropped = master == true && !metadata_master && master_key_suppressed?
183
+ master = nil if master_dropped
184
+
150
185
  if acl_user
151
186
  # Pre-resolved User-pointer path used by
152
187
  # Parse::Query#scope_to_user. Mirrors the session-token path
@@ -186,6 +221,18 @@ module Parse
186
221
  client: authorization_client(options))
187
222
  end
188
223
 
224
+ if master_dropped
225
+ if @require_session_token == true
226
+ raise ACLRequired,
227
+ "Parse::#{method_name} was called with master: true inside " \
228
+ "Parse.without_master_key, which drops master mode, and " \
229
+ "Parse::ACLScope.require_session_token refuses the public " \
230
+ "fallback. Pass session_token:, or run the call inside " \
231
+ "Parse.with_master_key if it needs master authority."
232
+ end
233
+ return public_resolution(options, master_dropped: true)
234
+ end
235
+
189
236
  if @require_session_token == true
190
237
  raise ACLRequired,
191
238
  "Parse::#{method_name} requires session_token: or master: true. " \
@@ -196,14 +243,7 @@ module Parse
196
243
  end
197
244
 
198
245
  warn_no_acl_context_once!(method_name)
199
- anonymous = Parse::Authorization::Resolved.new(nil, Set.new)
200
- Resolution.new(
201
- mode: :public,
202
- permission_strings: anonymous.permission_strings,
203
- user_id: nil,
204
- session: anonymous,
205
- client: authorization_client(options),
206
- )
246
+ public_resolution(options)
207
247
  end
208
248
 
209
249
  # Compile the `_rperm` `$match` stage to prepend to a mongo-direct
@@ -266,6 +306,27 @@ module Parse
266
306
  # * **`$facet`** — recursive: each facet branch is itself a
267
307
  # pipeline; rewrite every branch independently.
268
308
  #
309
+ # Each joined class is also held to its own Class-Level Permissions,
310
+ # the way Parse Server's include sub-query would be:
311
+ #
312
+ # * its `find` CLP is evaluated with Parse Server's branch
313
+ # semantics ({Parse::CLPScope.row_constraint_for!}). A denial
314
+ # refuses the whole join (fail closed). A grant that rests only
315
+ # on `readUserFields` / `pointerFields` adds the "row points at
316
+ # the requesting user" predicate to the join's leading `$match`
317
+ # (or to `restrictSearchWithMatch` for `$graphLookup`);
318
+ # * its protectedFields for the scope are removed by a
319
+ # {Parse::CLPScope.protected_strip_stage} placed right after that
320
+ # `$match`, before any caller stage of the sub-pipeline. Joined
321
+ # rows therefore never carry a protected field into the outer
322
+ # pipeline, whatever later stages reshape them, and a `_User`
323
+ # row keeps its own `email` only when its STORED `_id` is the
324
+ # requesting user.
325
+ #
326
+ # When `class_name:` names the queried class, each top-level `$facet`
327
+ # branch also starts with that class's strip stage: a branch nests the
328
+ # documents one level down, out of reach of the top-level strip.
329
+ #
269
330
  # Returns a NEW Array; the input pipeline is not mutated.
270
331
  # Master and nil-resolution pass through unchanged. Legacy
271
332
  # (non-strict-role) empty-perms resolutions also pass through.
@@ -275,8 +336,12 @@ module Parse
275
336
  #
276
337
  # @param pipeline [Array<Hash>] the aggregation pipeline.
277
338
  # @param resolution [Resolution, nil]
339
+ # @param class_name [String, nil] the queried class, for `$facet`
340
+ # branch stripping.
278
341
  # @return [Array<Hash>] the rewritten pipeline.
279
- def rewrite_pipeline(pipeline, resolution)
342
+ # @raise [Parse::CLPScope::Denied] when a joined class refuses `find`
343
+ # for the scope.
344
+ def rewrite_pipeline(pipeline, resolution, class_name: nil)
280
345
  return pipeline if pipeline.nil? || pipeline.empty?
281
346
  return pipeline if resolution.nil? || resolution.master?
282
347
  perms = resolution.permission_strings
@@ -301,7 +366,25 @@ module Parse
301
366
  # this gate; the rewriter is the shared SDK-level layer so the
302
367
  # mongo-direct path enforces it independent of whether an agent
303
368
  # made the call.
304
- pipeline.map { |stage| rewrite_stage(stage, acl_match, perms) }
369
+ ctx = JoinContext.new(acl_match, perms, resolution, clp_client_for(resolution))
370
+ pipeline.each_with_index.map do |stage, idx|
371
+ rewritten = rewrite_stage(stage, ctx)
372
+ next rewritten if class_name.nil?
373
+ strip_facet_branches(rewritten, class_name.to_s, ctx, pipeline[0...idx])
374
+ end
375
+ end
376
+
377
+ # Whether {.rewrite_pipeline} rewrites join stages for this
378
+ # resolution (and so places the protected-field strip at the head of
379
+ # every join). False for master, nil, and legacy empty-perms scopes.
380
+ #
381
+ # @param resolution [Resolution, nil]
382
+ # @return [Boolean]
383
+ def rewrites_joins?(resolution)
384
+ return false if resolution.nil? || resolution.master?
385
+ perms = resolution.permission_strings
386
+ strict = resolution.respond_to?(:strict_role?) && resolution.strict_role?
387
+ strict || !(perms.nil? || perms.empty?)
305
388
  end
306
389
 
307
390
  # Walk the result documents and redact every embedded sub-document
@@ -336,34 +419,70 @@ module Parse
336
419
 
337
420
  private
338
421
 
422
+ # Per-call state shared by the join rewriters.
423
+ JoinContext = Struct.new(:acl_match, :perms, :resolution, :client)
424
+
425
+ # The client whose schema decides CLP for a resolution. A user-scoped
426
+ # client (from `become` / `session_client`) holds no master key and
427
+ # cannot read `/schemas`, so it falls back to the default client (nil).
428
+ # Mirrors {Parse::MongoDB.clp_client_for}.
429
+ def clp_client_for(resolution)
430
+ c = client_of(resolution)
431
+ return nil if c.nil?
432
+ return nil unless c.respond_to?(:master_key) && !c.master_key.to_s.empty?
433
+ c
434
+ end
435
+
339
436
  # Apply the rewriter to a single pipeline stage. Operator-aware:
340
437
  # only join-style stages are touched. Everything else passes
341
- # through verbatim. `perms` is threaded down so the cross-class
342
- # CLP gate (Wave-3 TRACK-ACL-3) can challenge each joined class.
343
- def rewrite_stage(stage, acl_match, perms)
438
+ # through verbatim. The context is threaded down so the
439
+ # cross-class CLP gate (Wave-3 TRACK-ACL-3) can challenge each
440
+ # joined class.
441
+ def rewrite_stage(stage, ctx)
344
442
  return stage unless stage.is_a?(Hash)
345
443
  op_key, op_val = stage.first
346
444
  case op_key.to_s
347
445
  when "$lookup"
348
- { op_key => rewrite_lookup(op_val, acl_match, perms) }
446
+ { op_key => rewrite_lookup(op_val, ctx) }
349
447
  when "$unionWith"
350
- { op_key => rewrite_union_with(op_val, acl_match, perms) }
448
+ { op_key => rewrite_union_with(op_val, ctx) }
351
449
  when "$graphLookup"
352
- { op_key => rewrite_graph_lookup(op_val, acl_match, perms) }
450
+ { op_key => rewrite_graph_lookup(op_val, ctx) }
353
451
  when "$facet"
354
- { op_key => rewrite_facet(op_val, acl_match, perms) }
452
+ { op_key => rewrite_facet(op_val, ctx) }
355
453
  else
356
454
  stage
357
455
  end
358
456
  end
359
457
 
360
- # Cross-class CLP gate. Raises {Parse::CLPScope::Denied} when
361
- # the current scope cannot `find` rows of `target_class`. Master
362
- # mode is already short-circuited in {.rewrite_pipeline} (it
363
- # never reaches the rewriters), so reaching this helper means
364
- # `perms` is a real claim set. Centralized here to avoid drift
365
- # between the three join-style rewriters.
366
- def assert_join_target_permitted!(target, perms)
458
+ # Cross-class CLP gate for a joined class. Applies the hard
459
+ # internal-collection floor, then evaluates the joined class's `find`
460
+ # CLP with Parse Server's branch semantics. Raises
461
+ # {Parse::CLPScope::Denied} when the scope cannot `find` rows of
462
+ # `target` (including an unresolvable CLP: fail closed). Returns the
463
+ # `$match` predicate the joined rows must satisfy: the ACL predicate,
464
+ # ANDed with the pointer-ownership predicate when the grant rests on
465
+ # `readUserFields` / `pointerFields`.
466
+ #
467
+ # Master mode is short-circuited in {.rewrite_pipeline}, so reaching
468
+ # this helper means `perms` is a real claim set.
469
+ def join_predicate!(target, ctx)
470
+ acl_predicate = ctx.acl_match["$match"]
471
+ return acl_predicate if target.nil? || target.to_s.empty?
472
+ target_str = target.to_s
473
+ assert_join_target_permitted!(target_str, ctx.perms, client: ctx.client)
474
+ pointer_fields = Parse::CLPScope.row_constraint_for!(
475
+ target_str, :find, ctx.resolution, client: ctx.client,
476
+ )
477
+ return acl_predicate if pointer_fields.nil?
478
+ ownership = Parse::CLPScope.pointer_fields_predicate(pointer_fields, ctx.resolution.user_id)
479
+ { "$and" => [acl_predicate, ownership] }
480
+ end
481
+
482
+ # Internal-collection floor plus the `permits?` CLP gate for a joined
483
+ # class. Raises {Parse::PipelineSecurity::Error} for an internal
484
+ # collection and {Parse::CLPScope::Denied} when CLP refuses `find`.
485
+ def assert_join_target_permitted!(target, perms, client: nil)
367
486
  return if target.nil?
368
487
  target_str = target.to_s
369
488
  return if target_str.empty?
@@ -378,22 +497,53 @@ module Parse
378
497
  # admitting the SDK data classes (`_User`/`_Role`/`_Installation`/
379
498
  # `_Session`), which then face the per-scope CLP `find` gate.
380
499
  Parse::PipelineSecurity.assert_collection_allowed!(target_str)
381
- return if Parse::CLPScope.permits?(target_str, :find, perms)
500
+ return if Parse::CLPScope.permits?(target_str, :find, perms, client: client)
382
501
  raise Parse::CLPScope::Denied.new(
383
502
  target_str, :find,
384
503
  "Joined class '#{target_str}' refuses :find for current scope.",
385
504
  )
386
505
  end
387
506
 
388
- def rewrite_lookup(spec, acl_match, perms)
507
+ # The stages that lead a join's sub-pipeline: the row filter, then
508
+ # the joined class's protected-field strip (when it has any).
509
+ def join_head_stages!(target, ctx)
510
+ head = [{ "$match" => join_predicate!(target, ctx) }]
511
+ strip = strip_stage_for(target, ctx)
512
+ head << strip if strip
513
+ head
514
+ end
515
+
516
+ def strip_stage_for(target, ctx, user_id: ctx.resolution.user_id)
517
+ return nil if target.nil? || target.to_s.empty?
518
+ set = Parse::CLPScope.protected_fields_for(target.to_s, ctx.perms, client: ctx.client)
519
+ Parse::CLPScope.protected_strip_stage(set, class_name: target.to_s, user_id: user_id)
520
+ end
521
+
522
+ # Prepend the queried class's strip stage to every branch of a
523
+ # top-level `$facet`. The `_User` self exemption is kept only when no
524
+ # earlier stage can have rewritten `_id`.
525
+ def strip_facet_branches(stage, class_name, ctx, preceding)
526
+ return stage unless stage.is_a?(Hash)
527
+ op_key, op_val = stage.first
528
+ return stage unless op_key.to_s == "$facet" && op_val.is_a?(Hash)
529
+ stable = Parse::PipelineSecurity.identity_preserving?(preceding)
530
+ strip = strip_stage_for(class_name, ctx, user_id: stable ? ctx.resolution.user_id : nil)
531
+ return stage if strip.nil?
532
+ branches = op_val.each_with_object({}) do |(name, branch), out|
533
+ out[name] = branch.is_a?(Array) ? [strip] + branch : branch
534
+ end
535
+ { op_key => branches }
536
+ end
537
+
538
+ def rewrite_lookup(spec, ctx)
389
539
  # String shorthand `{$lookup: "Collection"}` is not a real
390
540
  # Mongo form; defensively leave it alone.
391
541
  return spec unless spec.is_a?(Hash)
392
542
  # Gate FIRST so a CLP-denied join is refused before the
393
543
  # rewriter spends work rebuilding the sub-pipeline. `from`
394
- # accepts string or symbol — normalize via the gate.
544
+ # accepts string or symbol; the gate normalizes it.
395
545
  target = spec["from"] || spec[:from]
396
- assert_join_target_permitted!(target, perms)
546
+ head = join_head_stages!(target, ctx)
397
547
  spec = spec.dup
398
548
  existing_pipeline = spec["pipeline"] || spec[:pipeline] || []
399
549
  # Walk the sub-pipeline recursively so nested $lookup /
@@ -402,69 +552,77 @@ module Parse
402
552
  # `perms` set. (Mongo evaluates the sub-pipeline in the
403
553
  # joined collection's context, but the requesting session is
404
554
  # unchanged; permissions don't elevate by traversing a join.)
405
- rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, acl_match, perms) }
406
- new_pipeline = [acl_match] + rewritten_inner
407
- spec["pipeline"] = new_pipeline
555
+ rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, ctx) }
556
+ spec["pipeline"] = head + rewritten_inner
408
557
  spec.delete(:pipeline) # symbol form was promoted to string form
409
558
  spec
410
559
  end
411
560
 
412
- def rewrite_union_with(spec, acl_match, perms)
561
+ def rewrite_union_with(spec, ctx)
413
562
  # `$unionWith` accepts either a String (collection name only)
414
- # or a Hash `{coll:, pipeline:}`. Post the target from
415
- # either shape so the CLP gate fires before the String→Hash
416
- # upgrade — denying access to the joined class BEFORE we go
563
+ # or a Hash `{coll:, pipeline:}`. Resolve the target from
564
+ # either shape so the CLP gate fires before the String to Hash
565
+ # upgrade, denying access to the joined class BEFORE we go
417
566
  # to the trouble of building out an upgraded sub-pipeline.
418
567
  target = if spec.is_a?(String)
419
568
  spec
420
569
  elsif spec.is_a?(Hash)
421
570
  spec["coll"] || spec[:coll]
422
571
  end
423
- assert_join_target_permitted!(target, perms)
572
+ return spec unless spec.is_a?(String) || spec.is_a?(Hash)
573
+ head = join_head_stages!(target, ctx)
424
574
 
425
575
  if spec.is_a?(String)
426
- return { "coll" => spec, "pipeline" => [acl_match] }
576
+ return { "coll" => spec, "pipeline" => head }
427
577
  end
428
- return spec unless spec.is_a?(Hash)
429
578
  spec = spec.dup
430
579
  existing_pipeline = spec["pipeline"] || spec[:pipeline] || []
431
- rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, acl_match, perms) }
432
- spec["pipeline"] = [acl_match] + rewritten_inner
580
+ rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, ctx) }
581
+ spec["pipeline"] = head + rewritten_inner
433
582
  spec.delete(:pipeline)
434
583
  spec
435
584
  end
436
585
 
437
- def rewrite_graph_lookup(spec, acl_match, perms)
586
+ def rewrite_graph_lookup(spec, ctx)
438
587
  return spec unless spec.is_a?(Hash)
439
- # Same CLP gate, same reasoning — $graphLookup reads from a
588
+ # Same CLP gate, same reasoning: $graphLookup reads from a
440
589
  # different collection in the same session's authority.
441
590
  target = spec["from"] || spec[:from]
442
- assert_join_target_permitted!(target, perms)
591
+ predicate = join_predicate!(target, ctx)
592
+ # `$graphLookup` returns whole joined documents and has no
593
+ # sub-pipeline to strip protected fields in. Refuse it when the
594
+ # joined class has any for this scope.
595
+ if strip_stage_for(target, ctx)
596
+ raise Parse::CLPScope::Denied.new(
597
+ target.to_s, :find,
598
+ "$graphLookup into '#{target}' would return protected fields for the " \
599
+ "current scope; use $lookup, which strips them.",
600
+ )
601
+ end
443
602
  spec = spec.dup
444
603
  # `$graphLookup` doesn't accept a sub-pipeline. Its filter hook
445
604
  # is `restrictSearchWithMatch`, which is a $match-predicate (no
446
605
  # `$match` wrapper). Combine with any existing restriction via
447
606
  # `$and`.
448
- acl_predicate = acl_match["$match"]
449
607
  existing = spec["restrictSearchWithMatch"] || spec[:restrictSearchWithMatch]
450
608
  combined = if existing.nil? || (existing.respond_to?(:empty?) && existing.empty?)
451
- acl_predicate
609
+ predicate
452
610
  else
453
- { "$and" => [existing, acl_predicate] }
611
+ { "$and" => [existing, predicate] }
454
612
  end
455
613
  spec["restrictSearchWithMatch"] = combined
456
614
  spec.delete(:restrictSearchWithMatch)
457
615
  spec
458
616
  end
459
617
 
460
- def rewrite_facet(spec, acl_match, perms)
618
+ def rewrite_facet(spec, ctx)
461
619
  return spec unless spec.is_a?(Hash)
462
620
  spec.each_with_object({}) do |(branch_name, branch_pipeline), out|
463
621
  out[branch_name] = if branch_pipeline.is_a?(Array)
464
- # Recurse with the same perms — facet branches are
622
+ # Recurse with the same perms: facet branches are
465
623
  # evaluated in the requesting session's authority, not
466
624
  # elevated.
467
- branch_pipeline.map { |s| rewrite_stage(s, acl_match, perms) }
625
+ branch_pipeline.map { |s| rewrite_stage(s, ctx) }
468
626
  else
469
627
  branch_pipeline
470
628
  end
@@ -727,8 +885,30 @@ module Parse
727
885
  @warned_malformed_rperm_classes = Set.new
728
886
  end
729
887
 
888
+ # Whether an explicit `master: true` must be dropped: true inside a
889
+ # {Parse.without_master_key} block that no nested
890
+ # {Parse.with_master_key} has re-enabled.
891
+ # @!visibility private
892
+ # @return [Boolean]
893
+ def master_key_suppressed?
894
+ Parse.respond_to?(:master_key_disabled?) && Parse.master_key_disabled?
895
+ end
896
+
730
897
  private
731
898
 
899
+ # The anonymous public-scope resolution: `"*"` grants only.
900
+ def public_resolution(options, master_dropped: nil)
901
+ anonymous = Parse::Authorization::Resolved.new(nil, Set.new)
902
+ Resolution.new(
903
+ mode: :public,
904
+ permission_strings: anonymous.permission_strings,
905
+ user_id: nil,
906
+ session: anonymous,
907
+ client: authorization_client(options),
908
+ master_dropped: master_dropped,
909
+ )
910
+ end
911
+
732
912
  # Emit the once-per-process security banner the first time a
733
913
  # mongo-direct path runs without `session_token:` and without
734
914
  # `master: true`. Mirrors {Parse::AtlasSearch}'s warned-once
Binary file