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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +318 -0
  3. data/README.md +25 -19
  4. data/docs/caching.md +50 -0
  5. data/docs/mcp_guide.md +15 -3
  6. data/docs/webhooks_guide.md +59 -7
  7. data/lib/parse/acl_scope.rb +71 -9
  8. data/lib/parse/agent/describe.rb +8 -2
  9. data/lib/parse/agent/mcp_dispatcher.rb +8 -1
  10. data/lib/parse/agent.rb +205 -20
  11. data/lib/parse/atlas_search/index_manager.rb +5 -1
  12. data/lib/parse/atlas_search.rb +49 -1
  13. data/lib/parse/authorization.rb +235 -3
  14. data/lib/parse/cache/sub_cache.rb +26 -0
  15. data/lib/parse/client/authentication.rb +19 -1
  16. data/lib/parse/client/batch.rb +35 -2
  17. data/lib/parse/client.rb +26 -2
  18. data/lib/parse/clp_scope.rb +37 -3
  19. data/lib/parse/live_query/client.rb +72 -2
  20. data/lib/parse/lock_backend.rb +4 -3
  21. data/lib/parse/model/associations/collection_proxy.rb +19 -11
  22. data/lib/parse/model/associations/has_many.rb +11 -0
  23. data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
  24. data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
  25. data/lib/parse/model/classes/role.rb +23 -0
  26. data/lib/parse/model/classes/session.rb +279 -19
  27. data/lib/parse/model/classes/user.rb +33 -12
  28. data/lib/parse/model/core/actions.rb +19 -4
  29. data/lib/parse/model/core/fetching.rb +29 -2
  30. data/lib/parse/model/core/field_guards.rb +16 -8
  31. data/lib/parse/model/object.rb +52 -1
  32. data/lib/parse/model/push.rb +11 -1
  33. data/lib/parse/mongodb.rb +19 -1
  34. data/lib/parse/query/constraint.rb +1 -1
  35. data/lib/parse/query/constraints.rb +31 -27
  36. data/lib/parse/query.rb +793 -55
  37. data/lib/parse/stack/version.rb +1 -1
  38. data/lib/parse/stack.rb +19 -0
  39. data/lib/parse/vector_search/hybrid.rb +74 -15
  40. data/lib/parse/vector_search.rb +214 -7
  41. data/lib/parse/webhooks/payload.rb +43 -0
  42. data/lib/parse/webhooks.rb +312 -9
  43. metadata +1 -1
@@ -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. Two limits come
288
- from what Parse Server sends the webhook: a field you rewrite is written as an
289
- absolute value, and an operator on a dotted sub-key (`"meta.count"`) arrives
290
- only as its resulting sub-document and is written back that way. Edits made
291
- in place on `parse_object` count even if the handler returns `true`. On a
292
- create, returning `parse_object` also writes the model's declared defaults
293
- (including its default ACL) for fields the client did not send.
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).
@@ -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
@@ -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
@@ -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
- prev_approval_gate = agent.approval_gate if agent.respond_to?(:approval_gate)
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 ||= Parse::Agent::NullGate.new
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
- attr_writer :approval_gate
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 — explicit opt-in (caller wants faceted_search
1740
- # authority regardless of parent).
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 class-filter inheritance. Unlike `tools:` (which overrides
2058
- # outright), `classes:` clamps to the parent's effective set so a
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
- qualified = "#{class_name}.#{method_name}"
2356
+ parse_class = Parse::Agent.canonical_method_class(class_name)
2265
2357
 
2266
- if @method_filter_only
2267
- permitted = @method_filter_only.include?(method_sym) ||
2268
- @method_filter_only.include?(qualified)
2269
- return true unless permitted
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
- if @method_filter_except
2273
- excluded = @method_filter_except.include?(method_sym) ||
2274
- @method_filter_except.include?(qualified)
2275
- return true if excluded
2276
- end
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
- false
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?(".") ? str : str.to_sym
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
- results = Parse::MongoDB.aggregate(collection_name, pipeline, master: true)
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
@@ -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