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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +1148 -0
- data/README.md +39 -23
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/caching.md +50 -0
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +332 -9
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +126 -17
- data/lib/parse/acl_scope.rb +230 -50
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +11 -3
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +298 -21
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +144 -24
- data/lib/parse/authorization.rb +289 -4
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +264 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +227 -40
- data/lib/parse/clp_scope.rb +317 -25
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +146 -17
- data/lib/parse/model/associations/has_many.rb +49 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +95 -0
- data/lib/parse/model/classes/session.rb +304 -1
- data/lib/parse/model/classes/user.rb +104 -8
- data/lib/parse/model/core/actions.rb +287 -70
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +50 -5
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +317 -18
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +189 -61
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +31 -1
- data/lib/parse/query/constraints.rb +89 -59
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1871 -167
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +91 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +110 -33
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +259 -23
- data/lib/parse/webhooks/payload.rb +136 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +715 -40
- metadata +8 -1
data/docs/webhooks_guide.md
CHANGED
|
@@ -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`).
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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).
|
|
108
|
-
|
|
109
|
-
|
|
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.
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
- **
|
|
400
|
-
`
|
|
401
|
-
|
|
402
|
-
|
|
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
|
data/lib/parse/acl_scope.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
342
|
-
# CLP gate (Wave-3 TRACK-ACL-3) can challenge each
|
|
343
|
-
|
|
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,
|
|
446
|
+
{ op_key => rewrite_lookup(op_val, ctx) }
|
|
349
447
|
when "$unionWith"
|
|
350
|
-
{ op_key => rewrite_union_with(op_val,
|
|
448
|
+
{ op_key => rewrite_union_with(op_val, ctx) }
|
|
351
449
|
when "$graphLookup"
|
|
352
|
-
{ op_key => rewrite_graph_lookup(op_val,
|
|
450
|
+
{ op_key => rewrite_graph_lookup(op_val, ctx) }
|
|
353
451
|
when "$facet"
|
|
354
|
-
{ op_key => rewrite_facet(op_val,
|
|
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.
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
# `
|
|
365
|
-
#
|
|
366
|
-
|
|
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
|
-
|
|
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
|
|
544
|
+
# accepts string or symbol; the gate normalizes it.
|
|
395
545
|
target = spec["from"] || spec[:from]
|
|
396
|
-
|
|
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,
|
|
406
|
-
|
|
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,
|
|
561
|
+
def rewrite_union_with(spec, ctx)
|
|
413
562
|
# `$unionWith` accepts either a String (collection name only)
|
|
414
|
-
# or a Hash `{coll:, pipeline:}`.
|
|
415
|
-
# either shape so the CLP gate fires before the String
|
|
416
|
-
# upgrade
|
|
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
|
-
|
|
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" =>
|
|
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,
|
|
432
|
-
spec["pipeline"] =
|
|
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,
|
|
586
|
+
def rewrite_graph_lookup(spec, ctx)
|
|
438
587
|
return spec unless spec.is_a?(Hash)
|
|
439
|
-
# Same CLP gate, same reasoning
|
|
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
|
-
|
|
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
|
-
|
|
609
|
+
predicate
|
|
452
610
|
else
|
|
453
|
-
{ "$and" => [existing,
|
|
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,
|
|
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
|
|
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,
|
|
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
|