parse-stack-next 5.8.0 → 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 +318 -0
- data/README.md +25 -19
- data/docs/caching.md +50 -0
- data/docs/mcp_guide.md +15 -3
- data/docs/webhooks_guide.md +59 -7
- data/lib/parse/acl_scope.rb +71 -9
- data/lib/parse/agent/describe.rb +8 -2
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent.rb +205 -20
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search.rb +49 -1
- data/lib/parse/authorization.rb +235 -3
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +35 -2
- data/lib/parse/client.rb +26 -2
- data/lib/parse/clp_scope.rb +37 -3
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/associations/collection_proxy.rb +19 -11
- data/lib/parse/model/associations/has_many.rb +11 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
- data/lib/parse/model/classes/role.rb +23 -0
- data/lib/parse/model/classes/session.rb +279 -19
- data/lib/parse/model/classes/user.rb +33 -12
- data/lib/parse/model/core/actions.rb +19 -4
- data/lib/parse/model/core/fetching.rb +29 -2
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/object.rb +52 -1
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +19 -1
- data/lib/parse/query/constraint.rb +1 -1
- data/lib/parse/query/constraints.rb +31 -27
- data/lib/parse/query.rb +793 -55
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +19 -0
- data/lib/parse/vector_search/hybrid.rb +74 -15
- data/lib/parse/vector_search.rb +214 -7
- data/lib/parse/webhooks/payload.rb +43 -0
- data/lib/parse/webhooks.rb +312 -9
- metadata +1 -1
data/docs/webhooks_guide.md
CHANGED
|
@@ -284,13 +284,65 @@ database untouched. When the handler changed fields, the reply is the client's
|
|
|
284
284
|
full write with your changes layered on top: operators on fields you did not
|
|
285
285
|
touch are passed through as operators, fields the model does not declare (and
|
|
286
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.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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.
|
|
294
346
|
|
|
295
347
|
`after_destroy` callbacks run in the `after_delete` handler (once per delivery,
|
|
296
348
|
skipped for deletes the SDK itself made, whose callbacks already ran locally).
|
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
|
|
@@ -845,8 +885,30 @@ module Parse
|
|
|
845
885
|
@warned_malformed_rperm_classes = Set.new
|
|
846
886
|
end
|
|
847
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
|
+
|
|
848
897
|
private
|
|
849
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
|
+
|
|
850
912
|
# Emit the once-per-process security banner the first time a
|
|
851
913
|
# mongo-direct path runs without `session_token:` and without
|
|
852
914
|
# `master: true`. Mirrors {Parse::AtlasSearch}'s warned-once
|
data/lib/parse/agent/describe.rb
CHANGED
|
@@ -176,7 +176,8 @@ module Parse
|
|
|
176
176
|
tenant_id: tenant_id,
|
|
177
177
|
classes: filter_descriptor(@class_filter_only, @class_filter_except),
|
|
178
178
|
tools: tools_descriptor,
|
|
179
|
-
methods: filter_descriptor(@method_filter_only, @method_filter_except, transform: ->(s) { s.to_s })
|
|
179
|
+
methods: filter_descriptor(@method_filter_only, @method_filter_except, transform: ->(s) { s.to_s })
|
|
180
|
+
.merge(layers: method_filter_layers_descriptor),
|
|
180
181
|
filters: per_agent_filters_summary,
|
|
181
182
|
hidden_classes: Parse::Agent::MetadataRegistry.hidden_class_names,
|
|
182
183
|
per_class: per_class_descriptor,
|
|
@@ -383,10 +384,15 @@ module Parse
|
|
|
383
384
|
lines << " except: #{data[:tools][:except].inspect}" if data[:tools][:except]
|
|
384
385
|
lines << " effective: #{data[:tools][:effective].inspect}"
|
|
385
386
|
|
|
386
|
-
if data[:methods][:only] || data[:methods][:except]
|
|
387
|
+
inherited_layers = Array(data[:methods][:layers])[0...-1] if data[:methods][:only] || data[:methods][:except]
|
|
388
|
+
inherited_layers ||= Array(data[:methods][:layers])
|
|
389
|
+
if data[:methods][:only] || data[:methods][:except] || inherited_layers.any?
|
|
387
390
|
lines << " methods:"
|
|
388
391
|
lines << " only: #{data[:methods][:only].inspect}" if data[:methods][:only]
|
|
389
392
|
lines << " except: #{data[:methods][:except].inspect}" if data[:methods][:except]
|
|
393
|
+
inherited_layers.each do |layer|
|
|
394
|
+
lines << " inherited: #{layer.compact.inspect}"
|
|
395
|
+
end
|
|
390
396
|
end
|
|
391
397
|
|
|
392
398
|
if data[:filters]
|
|
@@ -229,7 +229,14 @@ module Parse
|
|
|
229
229
|
# token.
|
|
230
230
|
prev_progress_callback = agent.progress_callback if agent.respond_to?(:progress_callback)
|
|
231
231
|
prev_cancellation_token = agent.cancellation_token if agent.respond_to?(:cancellation_token)
|
|
232
|
-
|
|
232
|
+
# Snapshot the agent's OWN gate, not the effective one: a sub-agent
|
|
233
|
+
# with no gate of its own reads its parent's, and restoring that
|
|
234
|
+
# copy would pin it and stop the delegation.
|
|
235
|
+
prev_approval_gate = if agent.respond_to?(:approval_gate_override)
|
|
236
|
+
agent.approval_gate_override
|
|
237
|
+
elsif agent.respond_to?(:approval_gate)
|
|
238
|
+
agent.approval_gate
|
|
239
|
+
end
|
|
233
240
|
prev_log_callback = agent.log_callback if agent.respond_to?(:log_callback)
|
|
234
241
|
|
|
235
242
|
# Install the progress callback and cancellation token on the
|
data/lib/parse/agent.rb
CHANGED
|
@@ -1035,11 +1035,35 @@ module Parse
|
|
|
1035
1035
|
# `progress_callback` / `cancellation_token` are threaded. An
|
|
1036
1036
|
# embedder on the non-MCP path may assign any object responding to
|
|
1037
1037
|
# `#review`.
|
|
1038
|
+
#
|
|
1039
|
+
# A sub-agent built with `parent:` and no gate of its own uses its
|
|
1040
|
+
# parent's gate, read at call time: the dispatcher swaps the
|
|
1041
|
+
# parent's gate per request, so a copy taken at construction would
|
|
1042
|
+
# go stale. Without this a sub-agent fell back to {NullGate} and
|
|
1043
|
+
# ran tools in `require_approval_for` tiers with no approval.
|
|
1038
1044
|
def approval_gate
|
|
1039
|
-
@approval_gate
|
|
1045
|
+
return @approval_gate if @approval_gate
|
|
1046
|
+
return @approval_parent.approval_gate if @approval_parent
|
|
1047
|
+
@approval_gate_defaulted = true
|
|
1048
|
+
@approval_gate = Parse::Agent::NullGate.new
|
|
1049
|
+
end
|
|
1050
|
+
|
|
1051
|
+
# Assign this agent's own approval gate. Assigning nil removes it, so
|
|
1052
|
+
# a sub-agent goes back to using its parent's gate.
|
|
1053
|
+
def approval_gate=(gate)
|
|
1054
|
+
@approval_gate_defaulted = false
|
|
1055
|
+
@approval_gate = gate
|
|
1040
1056
|
end
|
|
1041
1057
|
|
|
1042
|
-
|
|
1058
|
+
# @return [Parse::Agent::ApprovalGate, nil] the gate assigned on this
|
|
1059
|
+
# agent itself, or nil when it uses its parent's (or the default).
|
|
1060
|
+
# The MCP dispatcher snapshots and restores this rather than
|
|
1061
|
+
# {#approval_gate}, so restoring never pins a parent's gate.
|
|
1062
|
+
# @api private
|
|
1063
|
+
def approval_gate_override
|
|
1064
|
+
gate = @approval_gate
|
|
1065
|
+
gate.is_a?(Parse::Agent::NullGate) && @approval_gate_defaulted ? nil : gate
|
|
1066
|
+
end
|
|
1043
1067
|
|
|
1044
1068
|
# @return [Boolean] true if the active cancellation token has been
|
|
1045
1069
|
# tripped; false otherwise. Returns false when no token is
|
|
@@ -1736,8 +1760,10 @@ module Parse
|
|
|
1736
1760
|
#
|
|
1737
1761
|
# * nil — inherit from parent (the common case; the
|
|
1738
1762
|
# child wants whatever the parent had).
|
|
1739
|
-
# * true
|
|
1740
|
-
#
|
|
1763
|
+
# * true: keep faceted_search authority. Allowed only when
|
|
1764
|
+
# the parent has it: a child cannot turn on
|
|
1765
|
+
# master-key faceting under a scoped parent, which
|
|
1766
|
+
# would return every row's buckets and results.
|
|
1741
1767
|
# * false — explicit opt-OUT: the sub-agent should DROP
|
|
1742
1768
|
# faceted_search authority even if the parent
|
|
1743
1769
|
# had it. Previously `false` was the default
|
|
@@ -1753,6 +1779,13 @@ module Parse
|
|
|
1753
1779
|
# per-row ACL via Parse::ACLScope's `_rperm` match and do
|
|
1754
1780
|
# NOT consult master_atlas.
|
|
1755
1781
|
master_atlas = parent.master_atlas if master_atlas.nil?
|
|
1782
|
+
if master_atlas == true && !parent.master_atlas
|
|
1783
|
+
raise ArgumentError,
|
|
1784
|
+
"sub-agent master_atlas: true exceeds parent's master_atlas: false. " \
|
|
1785
|
+
"A sub-agent cannot turn on master-key Atlas faceting its parent " \
|
|
1786
|
+
"was not given. Omit master_atlas to inherit the parent's setting, " \
|
|
1787
|
+
"or pass master_atlas: false."
|
|
1788
|
+
end
|
|
1756
1789
|
|
|
1757
1790
|
# Inherit cooperative cancellation surface. Without this, a
|
|
1758
1791
|
# delegating tool that constructs a sub-agent and drives it
|
|
@@ -1764,6 +1797,9 @@ module Parse
|
|
|
1764
1797
|
@cancellation_token = parent.cancellation_token
|
|
1765
1798
|
@progress_callback = parent.progress_callback
|
|
1766
1799
|
@log_callback = parent.log_callback
|
|
1800
|
+
# Approval gate: delegate to the parent's current gate (see
|
|
1801
|
+
# {#approval_gate}).
|
|
1802
|
+
@approval_parent = parent
|
|
1767
1803
|
|
|
1768
1804
|
# Clamp the sub-agent's permission tier at the parent's. The
|
|
1769
1805
|
# default :readonly is always ≤ any parent tier, so this fires
|
|
@@ -2054,8 +2090,63 @@ module Parse
|
|
|
2054
2090
|
Parse::AggregationResult.normalize_field_names!(field_names)
|
|
2055
2091
|
end
|
|
2056
2092
|
|
|
2057
|
-
# Sub-agent
|
|
2058
|
-
#
|
|
2093
|
+
# Sub-agent `tools:` inheritance: narrow only, same rule as `classes:`
|
|
2094
|
+
# below. Intersect onlies, union excepts. A child `only:` that leaves
|
|
2095
|
+
# nothing once the parent's allowlist and excepts apply raises; an
|
|
2096
|
+
# explicitly empty `only: []` is the strictest narrowing and is kept.
|
|
2097
|
+
if parent
|
|
2098
|
+
parent_tools_only = parent.instance_variable_get(:@tool_filter_only)
|
|
2099
|
+
parent_tools_except = parent.instance_variable_get(:@tool_filter_except)
|
|
2100
|
+
requested_tools = @tool_filter_only
|
|
2101
|
+
if parent_tools_only && @tool_filter_only
|
|
2102
|
+
@tool_filter_only = (@tool_filter_only & parent_tools_only).freeze
|
|
2103
|
+
elsif parent_tools_only
|
|
2104
|
+
@tool_filter_only = parent_tools_only
|
|
2105
|
+
end
|
|
2106
|
+
if parent_tools_except
|
|
2107
|
+
@tool_filter_except = (@tool_filter_except ? (@tool_filter_except | parent_tools_except) : parent_tools_except).freeze
|
|
2108
|
+
end
|
|
2109
|
+
if requested_tools && !requested_tools.empty? &&
|
|
2110
|
+
(parent_tools_only || parent_tools_except)
|
|
2111
|
+
reachable = @tool_filter_only - (@tool_filter_except || Set.new)
|
|
2112
|
+
if reachable.empty?
|
|
2113
|
+
raise ArgumentError,
|
|
2114
|
+
"sub-agent tools: { only: } would have no overlap with the parent's " \
|
|
2115
|
+
"tools: filter (parent only: #{parent_tools_only&.to_a&.sort.inspect}, " \
|
|
2116
|
+
"except: #{parent_tools_except&.to_a&.sort.inspect}; the child requested " \
|
|
2117
|
+
"#{requested_tools.to_a.sort.inspect}). A sub-agent cannot enable tools its " \
|
|
2118
|
+
"parent was not given. Pass a subset of the parent's tools, or omit tools: " \
|
|
2119
|
+
"to inherit the parent's filter."
|
|
2120
|
+
end
|
|
2121
|
+
end
|
|
2122
|
+
end
|
|
2123
|
+
|
|
2124
|
+
# Sub-agent `methods:` inheritance. Entries can be bare (`:archive`) or
|
|
2125
|
+
# qualified (`"Post.archive"`), so plain set intersection would get the
|
|
2126
|
+
# matching wrong. Instead the parent's filters are kept as layers and
|
|
2127
|
+
# {#method_filtered?} refuses a call any layer refuses. A child
|
|
2128
|
+
# `only:` that no parent layer could permit raises; an explicitly
|
|
2129
|
+
# empty `only: []` is kept.
|
|
2130
|
+
@method_filter_layers = (parent ? parent.method_filter_layers : []).dup
|
|
2131
|
+
if parent && @method_filter_only && !@method_filter_only.empty? && !@method_filter_layers.empty?
|
|
2132
|
+
reachable = @method_filter_only.any? do |entry|
|
|
2133
|
+
@method_filter_layers.all? { |layer_only, layer_except| method_entry_reachable?(entry, layer_only, layer_except) }
|
|
2134
|
+
end
|
|
2135
|
+
unless reachable
|
|
2136
|
+
raise ArgumentError,
|
|
2137
|
+
"sub-agent methods: { only: } would have no overlap with the parent's " \
|
|
2138
|
+
"methods: filter (child requested #{@method_filter_only.to_a.map(&:to_s).sort.inspect}). " \
|
|
2139
|
+
"A sub-agent cannot call agent methods its parent was not given. Pass a " \
|
|
2140
|
+
"subset of the parent's methods, or omit methods: to inherit them."
|
|
2141
|
+
end
|
|
2142
|
+
end
|
|
2143
|
+
if @method_filter_only || @method_filter_except
|
|
2144
|
+
@method_filter_layers << [@method_filter_only, @method_filter_except].freeze
|
|
2145
|
+
end
|
|
2146
|
+
@method_filter_layers.freeze
|
|
2147
|
+
|
|
2148
|
+
# Sub-agent class-filter inheritance. Like `tools:` above,
|
|
2149
|
+
# `classes:` clamps to the parent's effective set so a
|
|
2059
2150
|
# sub-agent can NEVER widen its parent's data-reach. Intersect onlies,
|
|
2060
2151
|
# union excepts. A child `only:` that would have no overlap with the
|
|
2061
2152
|
# parent's effective set raises at construction — empty-onlyset means
|
|
@@ -2253,29 +2344,77 @@ module Parse
|
|
|
2253
2344
|
#
|
|
2254
2345
|
# An entry matches the invocation if it equals either the bare
|
|
2255
2346
|
# method name (`:archive`) or the qualified form (`"Class.archive"`).
|
|
2347
|
+
# The class part is compared by its Parse class name on both sides, so
|
|
2348
|
+
# `"User.reset"` and `"_User.reset"` name the same method whichever
|
|
2349
|
+
# spelling the entry or the caller used.
|
|
2256
2350
|
#
|
|
2257
2351
|
# @param method_name [Symbol, String]
|
|
2258
2352
|
# @param class_name [String]
|
|
2259
2353
|
# @return [Boolean] true if filtered (refuse), false if permitted
|
|
2260
2354
|
def method_filtered?(method_name, class_name:)
|
|
2261
|
-
return false if @method_filter_only.nil? && @method_filter_except.nil?
|
|
2262
|
-
|
|
2263
2355
|
method_sym = method_name.to_sym
|
|
2264
|
-
|
|
2356
|
+
parse_class = Parse::Agent.canonical_method_class(class_name)
|
|
2265
2357
|
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2358
|
+
# A sub-agent carries its parent's filters as earlier layers; its own
|
|
2359
|
+
# filter is the last layer. Any layer can refuse.
|
|
2360
|
+
method_filter_layers.any? do |only, except|
|
|
2361
|
+
(only && !method_entry_matches?(only, method_sym, parse_class)) ||
|
|
2362
|
+
(except && method_entry_matches?(except, method_sym, parse_class))
|
|
2270
2363
|
end
|
|
2364
|
+
end
|
|
2271
2365
|
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2366
|
+
# The Parse class name for the class part of a qualified `methods:`
|
|
2367
|
+
# entry or a `call_method` class argument: `"User"` and `"_User"`
|
|
2368
|
+
# both give `"_User"`. A name that resolves to no model is returned
|
|
2369
|
+
# unchanged.
|
|
2370
|
+
# @api private
|
|
2371
|
+
# @param name [String, Class]
|
|
2372
|
+
# @return [String]
|
|
2373
|
+
def self.canonical_method_class(name)
|
|
2374
|
+
return name.parse_class if name.is_a?(Class) && name.respond_to?(:parse_class)
|
|
2375
|
+
str = name.to_s
|
|
2376
|
+
klass = resolve_method_filter_class(str)
|
|
2377
|
+
klass ? klass.parse_class : str
|
|
2378
|
+
end
|
|
2277
2379
|
|
|
2278
|
-
|
|
2380
|
+
# Ruby constant names a `methods:` entry may use for a model whose Parse
|
|
2381
|
+
# class name differs (`class Artist < Parse::Object; parse_class
|
|
2382
|
+
# "Musician"; end`).
|
|
2383
|
+
RUBY_CONSTANT_NAME_RE = /\A[A-Z][A-Za-z0-9_]*(?:::[A-Z][A-Za-z0-9_]*)*\z/
|
|
2384
|
+
|
|
2385
|
+
# @!visibility private
|
|
2386
|
+
# The Parse::Object subclass a `methods:` class name refers to, by
|
|
2387
|
+
# Parse class name first and then by Ruby constant name, or nil.
|
|
2388
|
+
def self.resolve_method_filter_class(str)
|
|
2389
|
+
klass = begin
|
|
2390
|
+
Parse::Model.find_class(str)
|
|
2391
|
+
rescue StandardError
|
|
2392
|
+
nil
|
|
2393
|
+
end
|
|
2394
|
+
return klass if klass.is_a?(Class) && klass < Parse::Object
|
|
2395
|
+
return nil unless str.match?(RUBY_CONSTANT_NAME_RE)
|
|
2396
|
+
klass = begin
|
|
2397
|
+
Object.const_get(str)
|
|
2398
|
+
rescue StandardError, LoadError
|
|
2399
|
+
nil
|
|
2400
|
+
end
|
|
2401
|
+
klass.is_a?(Class) && klass < Parse::Object ? klass : nil
|
|
2402
|
+
end
|
|
2403
|
+
|
|
2404
|
+
# @return [Array<Array(Set, Set)>] the `methods:` filters in effect as
|
|
2405
|
+
# `[only, except]` pairs, own filter last; a sub-agent carries its
|
|
2406
|
+
# parent's layers first.
|
|
2407
|
+
def method_filter_layers
|
|
2408
|
+
@method_filter_layers || []
|
|
2409
|
+
end
|
|
2410
|
+
|
|
2411
|
+
# @return [Array<Hash>] {#method_filter_layers} as `{only:, except:}`
|
|
2412
|
+
# hashes of sorted name strings (nil when a layer has no such list),
|
|
2413
|
+
# parent layers first. Used by the audit payload and {#describe}.
|
|
2414
|
+
def method_filter_layers_descriptor
|
|
2415
|
+
method_filter_layers.map do |only, except|
|
|
2416
|
+
{ only: only && only.to_a.map(&:to_s).sort, except: except && except.to_a.map(&:to_s).sort }
|
|
2417
|
+
end
|
|
2279
2418
|
end
|
|
2280
2419
|
|
|
2281
2420
|
# @return [Boolean] whether unknown names in tools: raise vs. warn at
|
|
@@ -2507,6 +2646,15 @@ module Parse
|
|
|
2507
2646
|
payload[:tools_except] = @tool_filter_except.to_a.sort if @tool_filter_except
|
|
2508
2647
|
payload[:methods_only] = @method_filter_only.to_a.map(&:to_s).sort if @method_filter_only
|
|
2509
2648
|
payload[:methods_except] = @method_filter_except.to_a.map(&:to_s).sort if @method_filter_except
|
|
2649
|
+
# `methods_only` / `methods_except` are this agent's own filter. A
|
|
2650
|
+
# sub-agent is also bound by its parent's filters, so the full set in
|
|
2651
|
+
# force is emitted as layers (parent first) whenever any layer was
|
|
2652
|
+
# inherited, including a single parent layer on a child with no
|
|
2653
|
+
# filter of its own.
|
|
2654
|
+
own_method_layers = (@method_filter_only || @method_filter_except) ? 1 : 0
|
|
2655
|
+
if method_filter_layers.size > own_method_layers
|
|
2656
|
+
payload[:methods_layers] = method_filter_layers_descriptor
|
|
2657
|
+
end
|
|
2510
2658
|
# Per-agent per-class filters — emit class-name → field-name list,
|
|
2511
2659
|
# NOT the constraint values. Filter values can contain user-identifying
|
|
2512
2660
|
# data (`{ user_id: "abc123" }`, `{ org_id: tenant_uuid }`) that
|
|
@@ -3393,7 +3541,44 @@ module Parse
|
|
|
3393
3541
|
# strings (qualified-class.method match).
|
|
3394
3542
|
def normalize_method_filter_entry(value)
|
|
3395
3543
|
str = value.to_s
|
|
3396
|
-
str.include?(".")
|
|
3544
|
+
return str.to_sym unless str.include?(".")
|
|
3545
|
+
class_part, method_part = str.split(".", 2)
|
|
3546
|
+
if Parse::Agent.resolve_method_filter_class(class_part).nil?
|
|
3547
|
+
warn "[Parse::Agent] methods: entry #{str.inspect} names a class that does not " \
|
|
3548
|
+
"resolve to a loaded Parse::Object model (by Parse class name or Ruby " \
|
|
3549
|
+
"constant). It is matched by its class part as written; check the spelling."
|
|
3550
|
+
end
|
|
3551
|
+
"#{Parse::Agent.canonical_method_class(class_part)}.#{method_part}"
|
|
3552
|
+
end
|
|
3553
|
+
|
|
3554
|
+
# Whether a `methods:` filter set names this invocation, either bare or
|
|
3555
|
+
# qualified. Qualified entries are compared by Parse class name, which
|
|
3556
|
+
# also covers an entry whose class was not loaded at construction.
|
|
3557
|
+
def method_entry_matches?(set, method_sym, parse_class)
|
|
3558
|
+
return true if set.include?(method_sym) || set.include?("#{parse_class}.#{method_sym}")
|
|
3559
|
+
set.any? do |entry|
|
|
3560
|
+
next false unless entry.is_a?(String)
|
|
3561
|
+
class_part, method_part = entry.split(".", 2)
|
|
3562
|
+
method_part == method_sym.to_s && Parse::Agent.canonical_method_class(class_part) == parse_class
|
|
3563
|
+
end
|
|
3564
|
+
end
|
|
3565
|
+
|
|
3566
|
+
# Whether a child `methods:` only-entry could pass one parent layer.
|
|
3567
|
+
# A qualified entry ("Post.archive") passes when the layer names it or
|
|
3568
|
+
# its bare method. A bare entry (:archive) passes when the layer names
|
|
3569
|
+
# it bare or qualified on any class.
|
|
3570
|
+
def method_entry_reachable?(entry, layer_only, layer_except)
|
|
3571
|
+
if entry.is_a?(String)
|
|
3572
|
+
bare = entry.split(".", 2).last.to_sym
|
|
3573
|
+
return false if layer_except && (layer_except.include?(entry) || layer_except.include?(bare))
|
|
3574
|
+
return true unless layer_only
|
|
3575
|
+
layer_only.include?(entry) || layer_only.include?(bare)
|
|
3576
|
+
else
|
|
3577
|
+
return false if layer_except&.include?(entry)
|
|
3578
|
+
return true unless layer_only
|
|
3579
|
+
layer_only.include?(entry) ||
|
|
3580
|
+
layer_only.any? { |e| e.is_a?(String) && e.split(".", 2).last == entry.to_s }
|
|
3581
|
+
end
|
|
3397
3582
|
end
|
|
3398
3583
|
|
|
3399
3584
|
# Normalize the constructor's `classes:` kwarg into a [only_set,
|
|
@@ -65,7 +65,11 @@ module Parse
|
|
|
65
65
|
# `master: true` so the SDK's CLP layer skips this metadata
|
|
66
66
|
# pipeline. The mongo-side privilege check still applies
|
|
67
67
|
# (the underlying connection must hold `listSearchIndexes`).
|
|
68
|
-
|
|
68
|
+
# The metadata sentinel keeps that master mode inside a
|
|
69
|
+
# `Parse.without_master_key` block, which governs row access,
|
|
70
|
+
# without re-enabling the master key for anything else.
|
|
71
|
+
results = Parse::MongoDB.aggregate(collection_name, pipeline,
|
|
72
|
+
master: Parse::ACLScope::METADATA_MASTER)
|
|
69
73
|
cache_mutex.synchronize { cache_indexes(collection_name, results) }
|
|
70
74
|
results
|
|
71
75
|
rescue => e
|
data/lib/parse/atlas_search.rb
CHANGED
|
@@ -559,6 +559,7 @@ module Parse
|
|
|
559
559
|
unless resolution.master?
|
|
560
560
|
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
561
561
|
pipeline << acl_match if acl_match
|
|
562
|
+
pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
|
|
562
563
|
end
|
|
563
564
|
|
|
564
565
|
# Add filter if provided
|
|
@@ -660,6 +661,17 @@ module Parse
|
|
|
660
661
|
"accept that bucket counts include all rows, or use " \
|
|
661
662
|
"#search for ACL-scoped results without facets."
|
|
662
663
|
end
|
|
664
|
+
# Inside Parse.without_master_key the only scope left is public,
|
|
665
|
+
# and $searchMeta bucket counts are not ACL-filtered there either,
|
|
666
|
+
# so they would count rows the block is meant to hide. Fail closed.
|
|
667
|
+
if Parse::ACLScope.master_key_suppressed?
|
|
668
|
+
raise FacetedSearchNotACLSafe,
|
|
669
|
+
"Parse::AtlasSearch.faceted_search cannot run inside " \
|
|
670
|
+
"Parse.without_master_key: $searchMeta bucket counts include " \
|
|
671
|
+
"rows the caller cannot read. Wrap the call in " \
|
|
672
|
+
"Parse.with_master_key to accept master-key counts, or use " \
|
|
673
|
+
"#search for ACL-scoped results without facets."
|
|
674
|
+
end
|
|
663
675
|
# Wave-3b READPREF-4: see #search for rationale. Captured
|
|
664
676
|
# before resolve_scope! pops the auth kwargs so the recursive
|
|
665
677
|
# search() call below can re-thread it explicitly (resolve!
|
|
@@ -815,6 +827,7 @@ module Parse
|
|
|
815
827
|
unless resolution.master?
|
|
816
828
|
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
817
829
|
pipeline << acl_match if acl_match
|
|
830
|
+
pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
|
|
818
831
|
end
|
|
819
832
|
|
|
820
833
|
# Caller-supplied filter, sanitized against operator injection
|
|
@@ -851,6 +864,8 @@ module Parse
|
|
|
851
864
|
unless resolution.master?
|
|
852
865
|
Parse::ACLScope.redact_results!(raw_results, resolution)
|
|
853
866
|
Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
|
|
867
|
+
# The pointerFields `$match` above already ran before `$limit`;
|
|
868
|
+
# this re-check is defense in depth and should drop nothing.
|
|
854
869
|
if pointer_fields
|
|
855
870
|
raw_results = Parse::CLPScope.filter_by_pointer_fields(
|
|
856
871
|
raw_results, pointer_fields, resolution.user_id,
|
|
@@ -972,13 +987,37 @@ module Parse
|
|
|
972
987
|
return Parse::ACLScope.resolve_for_role(acl_role, client: auth_client)
|
|
973
988
|
end
|
|
974
989
|
|
|
975
|
-
if master == true
|
|
990
|
+
if master == true && !Parse::ACLScope.master_key_suppressed?
|
|
976
991
|
return Parse::ACLScope::Resolution.new(
|
|
977
992
|
mode: :master, permission_strings: nil, user_id: nil, session: nil,
|
|
978
993
|
client: auth_client,
|
|
979
994
|
)
|
|
980
995
|
end
|
|
981
996
|
|
|
997
|
+
# Inside `Parse.without_master_key` an explicit master request runs
|
|
998
|
+
# in the public scope, as Parse::ACLScope.resolve! does: no
|
|
999
|
+
# no-ACL banner (the caller did pass a scope), and either strict
|
|
1000
|
+
# flag refuses the fallback.
|
|
1001
|
+
if master == true
|
|
1002
|
+
if @require_session_token == true || Parse::ACLScope.require_session_token == true
|
|
1003
|
+
raise ACLRequired,
|
|
1004
|
+
"Parse::AtlasSearch.#{method_name} was called with master: true " \
|
|
1005
|
+
"inside Parse.without_master_key, which drops master mode, and " \
|
|
1006
|
+
"require_session_token refuses the public fallback. Pass " \
|
|
1007
|
+
"session_token:, or run the call inside Parse.with_master_key " \
|
|
1008
|
+
"if it needs master authority."
|
|
1009
|
+
end
|
|
1010
|
+
anonymous = Session::Resolved.new(nil, Set.new)
|
|
1011
|
+
return Parse::ACLScope::Resolution.new(
|
|
1012
|
+
mode: :public,
|
|
1013
|
+
permission_strings: anonymous.permission_strings,
|
|
1014
|
+
user_id: nil,
|
|
1015
|
+
session: anonymous,
|
|
1016
|
+
client: auth_client,
|
|
1017
|
+
master_dropped: true,
|
|
1018
|
+
)
|
|
1019
|
+
end
|
|
1020
|
+
|
|
982
1021
|
if @require_session_token == true
|
|
983
1022
|
raise ACLRequired,
|
|
984
1023
|
"Parse::AtlasSearch.#{method_name} requires session_token: or " \
|
|
@@ -1037,6 +1076,15 @@ module Parse
|
|
|
1037
1076
|
label: "Atlas Search")
|
|
1038
1077
|
end
|
|
1039
1078
|
|
|
1079
|
+
# The pointerFields / readUserFields ownership constraint as a
|
|
1080
|
+
# `$match` stage, placed after the ACL `$match` and before `$sort` /
|
|
1081
|
+
# `$limit`. Filtering the fetched rows afterwards returned short or
|
|
1082
|
+
# empty pages whenever the top-ranked hits belonged to other users,
|
|
1083
|
+
# even though eligible matches ranked below them.
|
|
1084
|
+
def pointer_fields_match_stage(pointer_fields, resolution)
|
|
1085
|
+
{ "$match" => Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id) }
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1040
1088
|
# ATLAS-4: refuse `highlight_field:` when the field is in the
|
|
1041
1089
|
# resolved protectedFields set. searchHighlights returns the
|
|
1042
1090
|
# matched token plus surrounding chars verbatim; running it on
|