parse-stack-next 5.7.6 → 5.8.0
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 +830 -0
- data/README.md +14 -4
- 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/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +317 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- 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_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 +93 -1
- 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/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -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 +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- 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/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- 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 +21 -3
- 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 +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- 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 +1100 -134
- 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 +72 -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 +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
data/lib/parse/agent/tools.rb
CHANGED
|
@@ -823,8 +823,10 @@ module Parse
|
|
|
823
823
|
fields: {
|
|
824
824
|
type: "array",
|
|
825
825
|
items: { type: "string" },
|
|
826
|
-
description: "Optional. Restrict search to these fields. When omitted,
|
|
827
|
-
"
|
|
826
|
+
description: "Optional. Restrict search to these fields. When omitted, the fields this agent " \
|
|
827
|
+
"may read are searched (every indexed field only when the class declares no " \
|
|
828
|
+
"agent_fields). A session-scoped search on a class with protected fields must name " \
|
|
829
|
+
"fields, and may not name a protected one.",
|
|
828
830
|
},
|
|
829
831
|
limit: {
|
|
830
832
|
type: "integer",
|
|
@@ -1243,6 +1245,18 @@ module Parse
|
|
|
1243
1245
|
# its declared timeout (handled by Agent#execute and the approval
|
|
1244
1246
|
# preview, which both rescue it).
|
|
1245
1247
|
def invoke(agent, name, **kwargs)
|
|
1248
|
+
# Every tool runs with the agent's per-agent `fields:` narrowing in
|
|
1249
|
+
# scope, so each allowlist check (MetadataRegistry.field_allowlist)
|
|
1250
|
+
# resolves to the effective set for THIS agent.
|
|
1251
|
+
# The agent's data-field naming mode (Parse::Agent::FieldNames) is
|
|
1252
|
+
# scoped the same way; it changes presentation only.
|
|
1253
|
+
Parse::Agent::FieldPolicy.with(agent) do
|
|
1254
|
+
Parse::Agent::FieldNames.with(agent) { invoke_unscoped(agent, name, **kwargs) }
|
|
1255
|
+
end
|
|
1256
|
+
end
|
|
1257
|
+
|
|
1258
|
+
# @!visibility private
|
|
1259
|
+
def invoke_unscoped(agent, name, **kwargs)
|
|
1246
1260
|
sym = name.to_sym
|
|
1247
1261
|
entry = REGISTRY_MUTEX.synchronize { @registry[sym] }
|
|
1248
1262
|
|
|
@@ -2031,16 +2045,24 @@ module Parse
|
|
|
2031
2045
|
#
|
|
2032
2046
|
# Raises `Parse::Agent::AccessDenied` on any breach. The `Agent#execute`
|
|
2033
2047
|
# rescue chain translates that to `error_code: :access_denied`.
|
|
2048
|
+
#
|
|
2049
|
+
# @return [Array<String>, nil] the fields a result row may carry when
|
|
2050
|
+
# the source document reaches the output (no stage replaced the
|
|
2051
|
+
# schema), for {project_aggregate_rows}; nil when no projection is
|
|
2052
|
+
# needed (no `agent_fields`, or every output field was computed by a
|
|
2053
|
+
# validated stage).
|
|
2034
2054
|
def enforce_pipeline_access_policy!(class_name, pipeline, agent: nil)
|
|
2035
|
-
return unless pipeline.is_a?(Array)
|
|
2055
|
+
return nil unless pipeline.is_a?(Array)
|
|
2036
2056
|
source_permitted = compute_source_allowlist_for(class_name)
|
|
2037
|
-
walk_pipeline_with_state!(
|
|
2057
|
+
available, source_addressable = walk_pipeline_with_state!(
|
|
2038
2058
|
pipeline,
|
|
2039
2059
|
source_permitted: source_permitted,
|
|
2040
2060
|
available: [],
|
|
2041
2061
|
source_addressable: true,
|
|
2042
2062
|
agent: agent,
|
|
2043
2063
|
)
|
|
2064
|
+
return nil if source_permitted.nil? || !source_addressable
|
|
2065
|
+
source_permitted | available
|
|
2044
2066
|
end
|
|
2045
2067
|
|
|
2046
2068
|
module_function :enforce_pipeline_access_policy!
|
|
@@ -2058,6 +2080,26 @@ module Parse
|
|
|
2058
2080
|
|
|
2059
2081
|
module_function :compute_source_allowlist_for
|
|
2060
2082
|
|
|
2083
|
+
# @api private
|
|
2084
|
+
# Drop top-level keys outside `permitted` from aggregation rows whose
|
|
2085
|
+
# source document passed through unreplaced. Checking the fields a
|
|
2086
|
+
# pipeline REFERENCES is not enough: `[{ "$limit" => 1 }]` references
|
|
2087
|
+
# nothing and returns every column. A `_p_<field>` storage key is kept
|
|
2088
|
+
# when `<field>` is permitted, and `_id` / `objectId` always are.
|
|
2089
|
+
def project_aggregate_rows(rows, permitted)
|
|
2090
|
+
return rows if permitted.nil?
|
|
2091
|
+
keep = permitted.map(&:to_s) | %w[_id objectId]
|
|
2092
|
+
Array(rows).map do |row|
|
|
2093
|
+
next row unless row.is_a?(Hash)
|
|
2094
|
+
row.select do |key, _|
|
|
2095
|
+
k = key.to_s
|
|
2096
|
+
keep.include?(k) || (k.start_with?("_p_") && keep.include?(k.delete_prefix("_p_")))
|
|
2097
|
+
end
|
|
2098
|
+
end
|
|
2099
|
+
end
|
|
2100
|
+
|
|
2101
|
+
module_function :project_aggregate_rows
|
|
2102
|
+
|
|
2061
2103
|
# @api private
|
|
2062
2104
|
# Forward-pass walker. Maintains two pieces of state across stages:
|
|
2063
2105
|
# * `available` — fields introduced by upstream stages (Array<String>)
|
|
@@ -2088,6 +2130,7 @@ module Parse
|
|
|
2088
2130
|
available = (available | introduced)
|
|
2089
2131
|
end
|
|
2090
2132
|
end
|
|
2133
|
+
[available, source_addressable]
|
|
2091
2134
|
end
|
|
2092
2135
|
|
|
2093
2136
|
module_function :walk_pipeline_with_state!
|
|
@@ -2364,6 +2407,38 @@ module Parse
|
|
|
2364
2407
|
# MetadataRegistry.field_allowlist already merges in
|
|
2365
2408
|
# ALWAYS_KEEP_FIELDS (objectId / createdAt / updatedAt), so a
|
|
2366
2409
|
# join can carry the standard envelope without further work.
|
|
2410
|
+
# Join keys are reads too: joining on a hidden field and counting
|
|
2411
|
+
# the matches reveals its values even when the output is
|
|
2412
|
+
# projected away afterward. Source-side keys and `let`/
|
|
2413
|
+
# `startWith` expressions use the source allowlist; foreign-side
|
|
2414
|
+
# keys (and `restrictSearchWithMatch`) use the joined class's.
|
|
2415
|
+
if value.is_a?(Hash)
|
|
2416
|
+
opts = value.transform_keys(&:to_s)
|
|
2417
|
+
foreign_permitted = target_str ? compute_source_allowlist_for(target_str) : nil
|
|
2418
|
+
if permitted_fields
|
|
2419
|
+
%w[localField].each do |key|
|
|
2420
|
+
next unless opts[key]
|
|
2421
|
+
root = join_key_root(opts[key])
|
|
2422
|
+
unless permitted_fields.include?(root)
|
|
2423
|
+
raise_allowlist_refusal!("join field", opts[key].to_s, root, permitted_fields)
|
|
2424
|
+
end
|
|
2425
|
+
end
|
|
2426
|
+
opts["let"].each_value { |e| check_expression_for_restricted_fields!(e, permitted_fields) } if opts["let"].is_a?(Hash)
|
|
2427
|
+
check_expression_for_restricted_fields!(opts["startWith"], permitted_fields) if opts.key?("startWith")
|
|
2428
|
+
end
|
|
2429
|
+
if foreign_permitted
|
|
2430
|
+
%w[foreignField connectFromField connectToField].each do |key|
|
|
2431
|
+
next unless opts[key]
|
|
2432
|
+
root = join_key_root(opts[key])
|
|
2433
|
+
unless foreign_permitted.include?(root)
|
|
2434
|
+
raise_allowlist_refusal!("join field", opts[key].to_s, root, foreign_permitted)
|
|
2435
|
+
end
|
|
2436
|
+
end
|
|
2437
|
+
if opts["restrictSearchWithMatch"].is_a?(Hash)
|
|
2438
|
+
check_match_keys_for_restricted_fields!(opts["restrictSearchWithMatch"], foreign_permitted)
|
|
2439
|
+
end
|
|
2440
|
+
end
|
|
2441
|
+
end
|
|
2367
2442
|
sub = value.is_a?(Hash) ? (value["pipeline"] || value[:pipeline]) : nil
|
|
2368
2443
|
if sub
|
|
2369
2444
|
# The lookup sub-pipeline runs against the FOREIGN class's
|
|
@@ -2503,6 +2578,20 @@ module Parse
|
|
|
2503
2578
|
|
|
2504
2579
|
module_function :walk_pipeline_stage!
|
|
2505
2580
|
|
|
2581
|
+
# @api private
|
|
2582
|
+
# Root field of a join key in Parse terms. Joins are written against
|
|
2583
|
+
# stored documents, so `_id`, `_created_at`, `_updated_at`, and a
|
|
2584
|
+
# `_p_<pointer>` column name the objectId, timestamps, and pointer.
|
|
2585
|
+
JOIN_STORAGE_NAMES = { "_id" => "objectId", "_created_at" => "createdAt",
|
|
2586
|
+
"_updated_at" => "updatedAt" }.freeze
|
|
2587
|
+
|
|
2588
|
+
def join_key_root(key)
|
|
2589
|
+
root = key.to_s.split(".").first.to_s
|
|
2590
|
+
JOIN_STORAGE_NAMES.fetch(root) { root.delete_prefix("_p_") }
|
|
2591
|
+
end
|
|
2592
|
+
|
|
2593
|
+
module_function :join_key_root
|
|
2594
|
+
|
|
2506
2595
|
# @api private
|
|
2507
2596
|
# Walk a $match hash refusing field keys outside the allowlist.
|
|
2508
2597
|
# Logical operators ($and/$or/$nor/$not) recurse. $expr expressions
|
|
@@ -2541,7 +2630,22 @@ module Parse
|
|
|
2541
2630
|
def check_expression_for_restricted_fields!(expr, permitted_fields)
|
|
2542
2631
|
case expr
|
|
2543
2632
|
when String
|
|
2544
|
-
if expr.start_with?("
|
|
2633
|
+
if expr.start_with?("$$")
|
|
2634
|
+
# `$$ROOT` and `$$CURRENT` are the whole document: `$$ROOT.secret`
|
|
2635
|
+
# is `$secret`, and a bare `$$ROOT` copies every field. Other
|
|
2636
|
+
# system and user variables (`$$NOW`, `$$this`, `let` names) do
|
|
2637
|
+
# not address the source document.
|
|
2638
|
+
var, path = expr.delete_prefix("$$").split(".", 2)
|
|
2639
|
+
if %w[ROOT CURRENT].include?(var)
|
|
2640
|
+
ref = path.to_s.split(".").first
|
|
2641
|
+
if ref.nil? || ref.empty?
|
|
2642
|
+
raise_allowlist_refusal!("field reference", expr, "$$#{var}", permitted_fields)
|
|
2643
|
+
end
|
|
2644
|
+
unless permitted_fields.include?(ref)
|
|
2645
|
+
raise_allowlist_refusal!("field reference", expr, ref, permitted_fields)
|
|
2646
|
+
end
|
|
2647
|
+
end
|
|
2648
|
+
elsif expr.start_with?("$")
|
|
2545
2649
|
ref = expr.sub(/\A\$/, "").split(".").first
|
|
2546
2650
|
return if ref.empty? || ref.start_with?("$")
|
|
2547
2651
|
unless permitted_fields.include?(ref)
|
|
@@ -3185,6 +3289,17 @@ module Parse
|
|
|
3185
3289
|
order: nil, keys: nil, include: nil,
|
|
3186
3290
|
apply_canonical_filter: true, format: nil, **_kwargs)
|
|
3187
3291
|
assert_class_accessible!(class_name, agent: agent, op: :find)
|
|
3292
|
+
# Hidden-field inference: the caller's own where:/order: may only
|
|
3293
|
+
# reference readable fields. Filtering or sorting on a field outside
|
|
3294
|
+
# the effective agent_fields allowlist reveals its value through
|
|
3295
|
+
# which rows match or how they are ordered. Checked on the CALLER's
|
|
3296
|
+
# constraints, before the server-owned tenant / per-agent /
|
|
3297
|
+
# canonical constraints are merged in.
|
|
3298
|
+
assert_where_fields_in_allowlist!(class_name, where)
|
|
3299
|
+
assert_fields_in_allowlist!(class_name, order_field_names(order).map { |f| wire_field_path(class_name, f) })
|
|
3300
|
+
# Send the names that were checked: `email` resolves to the declared
|
|
3301
|
+
# column it was validated as, never to a same-named hidden column.
|
|
3302
|
+
order = wire_order(class_name, order)
|
|
3188
3303
|
limit = [limit || Agent::DEFAULT_LIMIT, Agent::MAX_LIMIT].min
|
|
3189
3304
|
|
|
3190
3305
|
# Tenant scope enforcement: resolve before any query building so that
|
|
@@ -3255,7 +3370,7 @@ module Parse
|
|
|
3255
3370
|
# This blocks dangerous operators like $where, $function
|
|
3256
3371
|
translated_where = nil
|
|
3257
3372
|
if effective_where && !effective_where.empty?
|
|
3258
|
-
translated_where = ConstraintTranslator.translate(effective_where, agent)
|
|
3373
|
+
translated_where = ConstraintTranslator.translate(effective_where, agent, class_name)
|
|
3259
3374
|
query[:where] = translated_where.to_json
|
|
3260
3375
|
end
|
|
3261
3376
|
|
|
@@ -3343,6 +3458,9 @@ module Parse
|
|
|
3343
3458
|
# @return [Hash] count result
|
|
3344
3459
|
def count_objects(agent, class_name:, where: nil, apply_canonical_filter: true, **_kwargs)
|
|
3345
3460
|
assert_class_accessible!(class_name, agent: agent, op: :count)
|
|
3461
|
+
# Hidden-field inference: a count over a hidden field's values is an
|
|
3462
|
+
# oracle for that field, same as a filtered query.
|
|
3463
|
+
assert_where_fields_in_allowlist!(class_name, where)
|
|
3346
3464
|
# Tenant scope enforcement. TRACK-AGENT-7 split: per-agent filter is
|
|
3347
3465
|
# UNCONDITIONAL, canonical filter is LLM-controllable.
|
|
3348
3466
|
scope = resolve_tenant_scope!(agent, class_name)
|
|
@@ -3354,7 +3472,7 @@ module Parse
|
|
|
3354
3472
|
|
|
3355
3473
|
translated_where = nil
|
|
3356
3474
|
if effective_where && !effective_where.empty?
|
|
3357
|
-
translated_where = ConstraintTranslator.translate(effective_where, agent)
|
|
3475
|
+
translated_where = ConstraintTranslator.translate(effective_where, agent, class_name)
|
|
3358
3476
|
query[:where] = translated_where.to_json
|
|
3359
3477
|
end
|
|
3360
3478
|
|
|
@@ -3440,7 +3558,7 @@ module Parse
|
|
|
3440
3558
|
else
|
|
3441
3559
|
{ "$and" => [composed_filter, { "objectId" => object_id }] }
|
|
3442
3560
|
end
|
|
3443
|
-
translated_combined = ConstraintTranslator.translate(combined_where, agent)
|
|
3561
|
+
translated_combined = ConstraintTranslator.translate(combined_where, agent, class_name)
|
|
3444
3562
|
rows = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
3445
3563
|
execute_find_via_direct(
|
|
3446
3564
|
agent, class_name,
|
|
@@ -3465,7 +3583,7 @@ module Parse
|
|
|
3465
3583
|
# The three-layer ACL simulation in Parse::MongoDB.aggregate
|
|
3466
3584
|
# ensures the row is only returned when the agent's scope
|
|
3467
3585
|
# permits it.
|
|
3468
|
-
where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent)
|
|
3586
|
+
where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent, class_name)
|
|
3469
3587
|
rows = execute_find_via_direct(
|
|
3470
3588
|
agent, class_name,
|
|
3471
3589
|
where: where_id, limit: 1,
|
|
@@ -3574,7 +3692,7 @@ module Parse
|
|
|
3574
3692
|
base_in_where = { "objectId" => { "$in" => unique_ids } }
|
|
3575
3693
|
composed = apply_per_agent_filter_to_where(base_in_where, class_name, agent: agent)
|
|
3576
3694
|
composed = apply_canonical_filter_to_where(composed, class_name, agent: agent) if apply_canonical_filter
|
|
3577
|
-
translated_where = ConstraintTranslator.translate(composed, agent)
|
|
3695
|
+
translated_where = ConstraintTranslator.translate(composed, agent, class_name)
|
|
3578
3696
|
|
|
3579
3697
|
# Build query
|
|
3580
3698
|
query = {
|
|
@@ -3686,7 +3804,7 @@ module Parse
|
|
|
3686
3804
|
effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent)
|
|
3687
3805
|
translated_where = nil
|
|
3688
3806
|
if effective_where && !effective_where.empty?
|
|
3689
|
-
translated_where = ConstraintTranslator.translate(effective_where, agent)
|
|
3807
|
+
translated_where = ConstraintTranslator.translate(effective_where, agent, class_name)
|
|
3690
3808
|
query[:where] = translated_where.to_json
|
|
3691
3809
|
end
|
|
3692
3810
|
|
|
@@ -3760,7 +3878,7 @@ module Parse
|
|
|
3760
3878
|
# class's agent_fields allowlist on projection-style stages
|
|
3761
3879
|
# ($project, $addFields, $set, $unset, $replaceRoot). Without this
|
|
3762
3880
|
# the top-level assert_class_accessible! check is bypassable.
|
|
3763
|
-
enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
|
|
3881
|
+
output_fields = enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
|
|
3764
3882
|
|
|
3765
3883
|
# Auto-rewrite LLM-style $lookup stages into Parse-on-Mongo column
|
|
3766
3884
|
# form AFTER access policy has run on the LLM's original (logical)
|
|
@@ -3877,6 +3995,7 @@ module Parse
|
|
|
3877
3995
|
# placeholder) and not silently surfaced into the pointer_classes
|
|
3878
3996
|
# envelope map.
|
|
3879
3997
|
results = redact_hidden_classes!(results, agent: agent)
|
|
3998
|
+
results = project_aggregate_rows(results, output_fields)
|
|
3880
3999
|
|
|
3881
4000
|
# Pointer-column compaction. Default-on: a typical aggregate over
|
|
3882
4001
|
# a class with a high-cardinality pointer (e.g. author per row)
|
|
@@ -3986,6 +4105,7 @@ module Parse
|
|
|
3986
4105
|
formatted_value = value_field ? resolve_aggregation_field(class_name, validate_group_field!(value_field, name: :value_field)) : nil
|
|
3987
4106
|
|
|
3988
4107
|
pipeline = build_group_pipeline(
|
|
4108
|
+
class_name: class_name,
|
|
3989
4109
|
where: where,
|
|
3990
4110
|
group_field: formatted_group,
|
|
3991
4111
|
flatten_arrays: flatten_arrays,
|
|
@@ -4082,6 +4202,7 @@ module Parse
|
|
|
4082
4202
|
|
|
4083
4203
|
date_expr = build_date_group_expression(formatted_field, interval_sym, tz)
|
|
4084
4204
|
pipeline = build_group_pipeline(
|
|
4205
|
+
class_name: class_name,
|
|
4085
4206
|
where: where,
|
|
4086
4207
|
group_field: nil,
|
|
4087
4208
|
group_expression: date_expr,
|
|
@@ -4150,6 +4271,7 @@ module Parse
|
|
|
4150
4271
|
|
|
4151
4272
|
formatted_field = resolve_aggregation_field(class_name, validated_field)
|
|
4152
4273
|
pipeline = build_group_pipeline(
|
|
4274
|
+
class_name: class_name,
|
|
4153
4275
|
where: where,
|
|
4154
4276
|
group_field: formatted_field,
|
|
4155
4277
|
flatten_arrays: false,
|
|
@@ -4311,13 +4433,113 @@ module Parse
|
|
|
4311
4433
|
def assert_where_fields_in_allowlist!(class_name, where)
|
|
4312
4434
|
return unless where.is_a?(Hash) && !where.empty?
|
|
4313
4435
|
allowlist = MetadataRegistry.field_allowlist(class_name)
|
|
4314
|
-
|
|
4315
|
-
|
|
4316
|
-
|
|
4436
|
+
if allowlist && allowlist.any?
|
|
4437
|
+
permitted = allowlist.map(&:to_s) | MetadataRegistry::ALWAYS_KEEP_FIELDS
|
|
4438
|
+
# Compare the names ConstraintTranslator will actually send:
|
|
4439
|
+
# `play_count` and `created_at` are permitted as `playCount` and
|
|
4440
|
+
# `createdAt`, and a `_p_` storage prefix is stripped.
|
|
4441
|
+
check_match_keys_for_restricted_fields!(normalize_where_keys(class_name, where), permitted)
|
|
4442
|
+
end
|
|
4443
|
+
# Embedded subqueries are validated against their OWN target class,
|
|
4444
|
+
# whether or not the outer class declares an allowlist.
|
|
4445
|
+
assert_subquery_fields_in_allowlist!(where)
|
|
4317
4446
|
end
|
|
4318
4447
|
|
|
4319
4448
|
module_function :assert_where_fields_in_allowlist!
|
|
4320
4449
|
|
|
4450
|
+
# @api private
|
|
4451
|
+
# A copy of `where` whose field keys (top level and inside
|
|
4452
|
+
# `$and`/`$or`/`$nor`/`$not`) are rewritten to their wire names, the way
|
|
4453
|
+
# ConstraintTranslator resolves them: the class's `field_map` entry or
|
|
4454
|
+
# an exact declared server name, else lowerCamelCase. A `_p_` storage
|
|
4455
|
+
# prefix is stripped. Operator keys and values are left alone; this is
|
|
4456
|
+
# used only to compare against the allowlist.
|
|
4457
|
+
def normalize_where_keys(class_name, where)
|
|
4458
|
+
case where
|
|
4459
|
+
when Hash
|
|
4460
|
+
where.each_with_object({}) do |(key, value), out|
|
|
4461
|
+
ks = key.to_s
|
|
4462
|
+
if %w[$and $or $nor].include?(ks)
|
|
4463
|
+
out[ks] = Array(value).map { |sub| normalize_where_keys(class_name, sub) }
|
|
4464
|
+
elsif ks == "$not"
|
|
4465
|
+
out[ks] = normalize_where_keys(class_name, value)
|
|
4466
|
+
elsif ks.start_with?("$")
|
|
4467
|
+
out[ks] = value
|
|
4468
|
+
else
|
|
4469
|
+
out[wire_field_path(class_name, ks)] = value
|
|
4470
|
+
end
|
|
4471
|
+
end
|
|
4472
|
+
else
|
|
4473
|
+
where
|
|
4474
|
+
end
|
|
4475
|
+
end
|
|
4476
|
+
|
|
4477
|
+
module_function :normalize_where_keys
|
|
4478
|
+
|
|
4479
|
+
# @api private
|
|
4480
|
+
# Wire form of a (possibly dotted) field path: the root segment is
|
|
4481
|
+
# resolved like a property name, the rest is kept.
|
|
4482
|
+
def wire_field_path(class_name, path)
|
|
4483
|
+
root, rest = path.to_s.sub(/\A_p_/, "").split(".", 2)
|
|
4484
|
+
wire = MetadataRegistry.wire_field_names(class_name, [root]).first || root
|
|
4485
|
+
rest ? "#{wire}.#{rest}" : wire
|
|
4486
|
+
end
|
|
4487
|
+
|
|
4488
|
+
module_function :wire_field_path
|
|
4489
|
+
|
|
4490
|
+
# @api private
|
|
4491
|
+
# Walk a where: Hash for embedded subqueries (`$inQuery`, `$notInQuery`,
|
|
4492
|
+
# `$select`, `$dontSelect`) and validate each one's predicates (and a
|
|
4493
|
+
# `$select` key) against the target class's effective allowlist. An
|
|
4494
|
+
# equivalent subquery must not reach a field a direct query on that
|
|
4495
|
+
# class would be refused, or the subquery becomes an oracle for it.
|
|
4496
|
+
# Class accessibility of the embedded className is enforced separately
|
|
4497
|
+
# by ConstraintTranslator.
|
|
4498
|
+
def assert_subquery_fields_in_allowlist!(node)
|
|
4499
|
+
case node
|
|
4500
|
+
when Hash
|
|
4501
|
+
node.each do |key, value|
|
|
4502
|
+
case key.to_s
|
|
4503
|
+
when "$inQuery", "$notInQuery"
|
|
4504
|
+
next unless value.is_a?(Hash)
|
|
4505
|
+
target = value["className"] || value[:className]
|
|
4506
|
+
inner = value["where"] || value[:where]
|
|
4507
|
+
assert_where_fields_in_allowlist!(target.to_s, inner) if target
|
|
4508
|
+
when "$select", "$dontSelect"
|
|
4509
|
+
next unless value.is_a?(Hash)
|
|
4510
|
+
query = value["query"] || value[:query] || {}
|
|
4511
|
+
target = query["className"] || query[:className]
|
|
4512
|
+
next unless target
|
|
4513
|
+
assert_where_fields_in_allowlist!(target.to_s, query["where"] || query[:where])
|
|
4514
|
+
selected = value["key"] || value[:key]
|
|
4515
|
+
assert_fields_in_allowlist!(target.to_s, [selected]) if selected
|
|
4516
|
+
when "$relatedTo"
|
|
4517
|
+
# The relation column lives on the OWNING object's class, so
|
|
4518
|
+
# `count_objects(_User, $relatedTo: {object: Post#X, key:
|
|
4519
|
+
# "flaggedBy"})` would reveal a relation hidden from Post's
|
|
4520
|
+
# allowlist unless the key is checked against that class.
|
|
4521
|
+
next unless value.is_a?(Hash)
|
|
4522
|
+
# Resolve the owner the way the translator does (a pointer Hash,
|
|
4523
|
+
# a Parse::Pointer, or a "Class$id" string).
|
|
4524
|
+
owner_class = ConstraintTranslator.send(:related_to_owning_class, value)
|
|
4525
|
+
relation_key = value["key"] || value[:key]
|
|
4526
|
+
if relation_key && (owner_class.nil? || owner_class.to_s.empty?)
|
|
4527
|
+
raise Parse::Agent::AccessDenied.new(
|
|
4528
|
+
nil, "$relatedTo requires a resolvable owning-object class.", kind: :field_denied,
|
|
4529
|
+
)
|
|
4530
|
+
end
|
|
4531
|
+
assert_fields_in_allowlist!(owner_class.to_s, [relation_key]) if relation_key
|
|
4532
|
+
else
|
|
4533
|
+
assert_subquery_fields_in_allowlist!(value)
|
|
4534
|
+
end
|
|
4535
|
+
end
|
|
4536
|
+
when Array
|
|
4537
|
+
node.each { |item| assert_subquery_fields_in_allowlist!(item) }
|
|
4538
|
+
end
|
|
4539
|
+
end
|
|
4540
|
+
|
|
4541
|
+
module_function :assert_subquery_fields_in_allowlist!
|
|
4542
|
+
|
|
4321
4543
|
# @api private
|
|
4322
4544
|
# Verify each referenced field is within agent_fields (or the
|
|
4323
4545
|
# always-keep set) when an allowlist is declared on the class.
|
|
@@ -4329,15 +4551,42 @@ module Parse
|
|
|
4329
4551
|
root = raw.to_s.sub(/\A_p_/, "").split(".").first
|
|
4330
4552
|
next if root.nil? || root.empty?
|
|
4331
4553
|
unless permitted.include?(root)
|
|
4332
|
-
|
|
4333
|
-
|
|
4334
|
-
)
|
|
4554
|
+
# raise_allowlist_refusal! carries kind/denied_field/allowed_fields
|
|
4555
|
+
# as structured attributes. Passing the refusal Hash positionally
|
|
4556
|
+
# (as this did before 5.8) landed it in the class-name slot, so the
|
|
4557
|
+
# message was a stringified Hash and `kind` was lost.
|
|
4558
|
+
raise_allowlist_refusal!("field", raw.to_s, root, permitted)
|
|
4335
4559
|
end
|
|
4336
4560
|
end
|
|
4337
4561
|
end
|
|
4338
4562
|
|
|
4339
4563
|
module_function :assert_fields_in_allowlist!
|
|
4340
4564
|
|
|
4565
|
+
# @api private
|
|
4566
|
+
# Field names an `order:` value sorts on. Accepts Parse REST form
|
|
4567
|
+
# ("-createdAt,title"), an Array of such entries, or nil.
|
|
4568
|
+
def order_field_names(order)
|
|
4569
|
+
return [] if order.nil?
|
|
4570
|
+
Array(order).flat_map { |entry| entry.to_s.split(",") }
|
|
4571
|
+
.map { |f| f.strip.sub(/\A[-+]/, "") }
|
|
4572
|
+
.reject(&:empty?)
|
|
4573
|
+
end
|
|
4574
|
+
|
|
4575
|
+
module_function :order_field_names
|
|
4576
|
+
|
|
4577
|
+
# @api private
|
|
4578
|
+
# `order` rewritten to wire names, keeping each key's `-`/`+` prefix:
|
|
4579
|
+
# `"-play_count,title"` becomes `"-playCount,title"`.
|
|
4580
|
+
def wire_order(class_name, order)
|
|
4581
|
+
return order if order.nil?
|
|
4582
|
+
Array(order).flat_map { |entry| entry.to_s.split(",") }.map(&:strip).reject(&:empty?).map do |key|
|
|
4583
|
+
sign = key[/\A[-+]/].to_s
|
|
4584
|
+
"#{sign == "-" ? "-" : ""}#{wire_field_path(class_name, key.delete_prefix(sign))}"
|
|
4585
|
+
end.join(",")
|
|
4586
|
+
end
|
|
4587
|
+
|
|
4588
|
+
module_function :wire_order
|
|
4589
|
+
|
|
4341
4590
|
# @api private
|
|
4342
4591
|
# Resolve a wire-format field name to its MongoDB aggregation form.
|
|
4343
4592
|
# Pointer fields are auto-prefixed with `_p_` when the local Parse
|
|
@@ -4370,10 +4619,10 @@ module Parse
|
|
|
4370
4619
|
# nil we emit a bare $group with only _id (distinct).
|
|
4371
4620
|
def build_group_pipeline(where:, group_field:, flatten_arrays:,
|
|
4372
4621
|
accumulator_op:, value_field:, operation:,
|
|
4373
|
-
group_expression: nil, agent: nil)
|
|
4622
|
+
group_expression: nil, agent: nil, class_name: nil)
|
|
4374
4623
|
pipeline = []
|
|
4375
4624
|
if where.is_a?(Hash) && !where.empty?
|
|
4376
|
-
pipeline << { "$match" => ConstraintTranslator.translate(where, agent) }
|
|
4625
|
+
pipeline << { "$match" => ConstraintTranslator.translate(where, agent, class_name) }
|
|
4377
4626
|
end
|
|
4378
4627
|
if flatten_arrays && group_field
|
|
4379
4628
|
pipeline << { "$unwind" => "$#{group_field}" }
|
|
@@ -4772,6 +5021,17 @@ module Parse
|
|
|
4772
5021
|
|
|
4773
5022
|
# @api private
|
|
4774
5023
|
def export_via_query(agent, class_name:, where:, keys:, include:, order:, limit:, skip: nil, scope: nil)
|
|
5024
|
+
# Hidden-field inference: the caller's own where:/order: may only
|
|
5025
|
+
# reference readable fields. Filtering or sorting on a field outside
|
|
5026
|
+
# the effective agent_fields allowlist reveals its value through
|
|
5027
|
+
# which rows match or how they are ordered. Checked on the CALLER's
|
|
5028
|
+
# constraints, before the server-owned tenant / per-agent /
|
|
5029
|
+
# canonical constraints are merged in.
|
|
5030
|
+
assert_where_fields_in_allowlist!(class_name, where)
|
|
5031
|
+
assert_fields_in_allowlist!(class_name, order_field_names(order).map { |f| wire_field_path(class_name, f) })
|
|
5032
|
+
# Send the names that were checked: `email` resolves to the declared
|
|
5033
|
+
# column it was validated as, never to a same-named hidden column.
|
|
5034
|
+
order = wire_order(class_name, order)
|
|
4775
5035
|
# Reuse query_class's gates by routing through it directly.
|
|
4776
5036
|
# query_class returns a ResultFormatter-wrapped hash; we want the raw rows.
|
|
4777
5037
|
query = {}
|
|
@@ -4817,7 +5077,7 @@ module Parse
|
|
|
4817
5077
|
effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent)
|
|
4818
5078
|
translated_where = nil
|
|
4819
5079
|
if effective_where && !effective_where.empty?
|
|
4820
|
-
translated_where = ConstraintTranslator.translate(effective_where, agent)
|
|
5080
|
+
translated_where = ConstraintTranslator.translate(effective_where, agent, class_name)
|
|
4821
5081
|
query[:where] = translated_where.to_json
|
|
4822
5082
|
end
|
|
4823
5083
|
|
|
@@ -4844,7 +5104,7 @@ module Parse
|
|
|
4844
5104
|
# @api private
|
|
4845
5105
|
def export_via_aggregate(agent, class_name:, pipeline:, scope: nil)
|
|
4846
5106
|
PipelineValidator.validate!(pipeline)
|
|
4847
|
-
enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
|
|
5107
|
+
output_fields = enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
|
|
4848
5108
|
assert_joins_tenant_safe!(pipeline, scope)
|
|
4849
5109
|
# Prepend tenant scope $match before per-agent + canonical filter and auto-limit.
|
|
4850
5110
|
scoped_pipeline = apply_tenant_scope_to_pipeline(pipeline, scope)
|
|
@@ -4883,7 +5143,7 @@ module Parse
|
|
|
4883
5143
|
end
|
|
4884
5144
|
end
|
|
4885
5145
|
|
|
4886
|
-
redact_hidden_classes!(rows, agent: agent)
|
|
5146
|
+
project_aggregate_rows(redact_hidden_classes!(rows, agent: agent), output_fields)
|
|
4887
5147
|
end
|
|
4888
5148
|
|
|
4889
5149
|
module_function :export_via_aggregate
|
|
@@ -5075,6 +5335,9 @@ module Parse
|
|
|
5075
5335
|
# @return [Hash] query explanation
|
|
5076
5336
|
def explain_query(agent, class_name:, where: nil, **_kwargs)
|
|
5077
5337
|
assert_class_accessible!(class_name, agent: agent, op: :find)
|
|
5338
|
+
# Explain stats (nReturned) answer yes/no for any predicate, so a
|
|
5339
|
+
# hidden field must not be addressable here either.
|
|
5340
|
+
assert_where_fields_in_allowlist!(class_name, where)
|
|
5078
5341
|
# No direct-MongoDB equivalent of Parse Server's REST explain
|
|
5079
5342
|
# plan exists today, and routing this through master-key REST
|
|
5080
5343
|
# under an acl_user/acl_role agent would silently bypass the
|
|
@@ -5100,7 +5363,7 @@ module Parse
|
|
|
5100
5363
|
effective_where = apply_canonical_filter_to_where(effective_where, class_name, agent: agent)
|
|
5101
5364
|
|
|
5102
5365
|
if effective_where && !effective_where.empty?
|
|
5103
|
-
query[:where] = ConstraintTranslator.translate(effective_where, agent).to_json
|
|
5366
|
+
query[:where] = ConstraintTranslator.translate(effective_where, agent, class_name).to_json
|
|
5104
5367
|
end
|
|
5105
5368
|
|
|
5106
5369
|
response = agent.client.find_objects(class_name, query, **agent.request_opts)
|
|
@@ -5475,7 +5738,7 @@ module Parse
|
|
|
5475
5738
|
# @return [Hash, nil]
|
|
5476
5739
|
def run_explain(agent, class_name, where)
|
|
5477
5740
|
query = { explain: true, limit: 1 }
|
|
5478
|
-
query[:where] = ConstraintTranslator.translate(where, agent).to_json
|
|
5741
|
+
query[:where] = ConstraintTranslator.translate(where, agent, class_name).to_json
|
|
5479
5742
|
response = agent.client.find_objects(class_name, query, **agent.request_opts)
|
|
5480
5743
|
return nil unless response.success?
|
|
5481
5744
|
response.result
|
|
@@ -5687,10 +5950,29 @@ module Parse
|
|
|
5687
5950
|
# also packs a reference to the assignee `_User`) would otherwise
|
|
5688
5951
|
# leak fields the conversational `query_class` tool would refuse
|
|
5689
5952
|
# to return.
|
|
5953
|
+
#
|
|
5954
|
+
# A returned Parse::Object is serialized from `as_json`, which carries
|
|
5955
|
+
# its values under the Parse (wire) field names, including explicit
|
|
5956
|
+
# `field_map` aliases and nested JSON verbatim. Before 5.8 this read
|
|
5957
|
+
# `result.attributes`, which is the model's field TYPE map, so a
|
|
5958
|
+
# method returning an object emitted `{ "title" => :string }` instead
|
|
5959
|
+
# of its data. An AggregationResult (e.g. a method returning
|
|
5960
|
+
# `query.aggregate(...).results`) is emitted as a Hash: snake_case keys
|
|
5961
|
+
# by default, the aggregation's own keys under `field_names: :server`.
|
|
5962
|
+
# Before 5.8 it was emitted as its `inspect` String.
|
|
5690
5963
|
def serialize_result(result, agent: nil)
|
|
5691
5964
|
formatted = case result
|
|
5692
5965
|
when Parse::Object
|
|
5693
|
-
project_object_to_allowlist(result.parse_class, ResultFormatter.
|
|
5966
|
+
project_object_to_allowlist(result.parse_class, ResultFormatter.simplify_object(result.as_json))
|
|
5967
|
+
when Parse::AggregationResult
|
|
5968
|
+
source = Parse::Agent::FieldNames.server? ? result.raw : result.to_h
|
|
5969
|
+
row = source.each_with_object({}) { |(k, v), h| h[k.to_s] = serialize_result(v, agent: agent) }
|
|
5970
|
+
# A row is a computed shape with no owning class, so it cannot be
|
|
5971
|
+
# projected through an allowlist; Parse Server's internal columns
|
|
5972
|
+
# (`_rperm`, `_hashed_password`, `_auth_data_*`, ...) are removed
|
|
5973
|
+
# from it at every depth, as on every other aggregation path.
|
|
5974
|
+
Parse::PipelineSecurity.redact_internal_fields_deep!(row)
|
|
5975
|
+
row
|
|
5694
5976
|
when Array
|
|
5695
5977
|
result.map { |item| serialize_result(item, agent: agent) }
|
|
5696
5978
|
when Hash
|
|
@@ -5700,7 +5982,30 @@ module Parse
|
|
|
5700
5982
|
else
|
|
5701
5983
|
result.to_s
|
|
5702
5984
|
end
|
|
5703
|
-
redact_hidden_classes!(formatted, agent: agent)
|
|
5985
|
+
redact_hidden_classes!(project_embedded_objects(formatted), agent: agent)
|
|
5986
|
+
end
|
|
5987
|
+
|
|
5988
|
+
# @api private
|
|
5989
|
+
# Project every embedded Parse object in a formatted result through
|
|
5990
|
+
# its OWN class's effective allowlist (class `agent_fields` narrowed by
|
|
5991
|
+
# the executing agent's `fields:` policy). A method may return an
|
|
5992
|
+
# object whose included children, or a plain Hash holding object JSON,
|
|
5993
|
+
# carry fields the caller may not read; projecting only the top-level
|
|
5994
|
+
# class would leak them. An embedded object is any Hash carrying a
|
|
5995
|
+
# `className`, saved or not: an unsaved object has no `objectId` but
|
|
5996
|
+
# still holds field values. A bare pointer keeps only its className,
|
|
5997
|
+
# `__type`, and `objectId`, so projecting it is harmless.
|
|
5998
|
+
def project_embedded_objects(value)
|
|
5999
|
+
case value
|
|
6000
|
+
when Hash
|
|
6001
|
+
class_name = value["className"] || value[:className]
|
|
6002
|
+
projected = class_name ? project_object_to_allowlist(class_name.to_s, value) : value
|
|
6003
|
+
projected.each_with_object({}) { |(k, v), acc| acc[k] = project_embedded_objects(v) }
|
|
6004
|
+
when Array
|
|
6005
|
+
value.map { |item| project_embedded_objects(item) }
|
|
6006
|
+
else
|
|
6007
|
+
value
|
|
6008
|
+
end
|
|
5704
6009
|
end
|
|
5705
6010
|
|
|
5706
6011
|
# @api private
|
|
@@ -5799,6 +6104,15 @@ module Parse
|
|
|
5799
6104
|
limit = clamp_atlas_limit(limit)
|
|
5800
6105
|
auth = atlas_auth_options!(agent, tool: :atlas_text_search)
|
|
5801
6106
|
fields_norm = normalize_atlas_fields_with_allowlist!(class_name, fields)
|
|
6107
|
+
# Hidden-field inference: the caller's filter may only address
|
|
6108
|
+
# readable fields, and with no `fields:` the text search defaults to
|
|
6109
|
+
# the readable fields rather than every field (`wildcard: "*"`), so
|
|
6110
|
+
# neither which rows match nor their rank depends on a hidden field.
|
|
6111
|
+
assert_where_fields_in_allowlist!(class_name, filter) if filter.is_a?(Hash)
|
|
6112
|
+
fields_norm ||= readable_atlas_text_fields(class_name)
|
|
6113
|
+
# An empty field list means `wildcard: "*"` to Atlas Search, which
|
|
6114
|
+
# would let hidden fields decide matches; refuse instead.
|
|
6115
|
+
refuse_empty_readable_text_fields!(class_name, fields_norm)
|
|
5802
6116
|
|
|
5803
6117
|
# TRACK-AGENT-6 / TRACK-AGENT-7 fix: per-agent filter is
|
|
5804
6118
|
# UNCONDITIONAL; canonical filter is LLM-controllable via
|
|
@@ -5911,9 +6225,19 @@ module Parse
|
|
|
5911
6225
|
# Parse::AtlasSearch::FacetedSearchNotACLSafe); pass master:
|
|
5912
6226
|
# true unconditionally here, since the agent-level gate
|
|
5913
6227
|
# above already enforced master_atlas?.
|
|
6228
|
+
readable = readable_atlas_text_fields(class_name)
|
|
6229
|
+
facet_opts = {}
|
|
6230
|
+
# A non-empty query searches only readable fields (never a
|
|
6231
|
+
# wildcard across hidden ones) when an allowlist applies.
|
|
6232
|
+
if readable && !query.to_s.strip.empty?
|
|
6233
|
+
refuse_empty_readable_text_fields!(class_name, readable)
|
|
6234
|
+
facet_opts[:fields] = readable
|
|
6235
|
+
end
|
|
6236
|
+
# Parse::AtlasSearch is loaded on first use, not with the agent.
|
|
6237
|
+
require_relative "../atlas_search"
|
|
5914
6238
|
result = Parse::AtlasSearch.faceted_search(
|
|
5915
6239
|
class_name, query.to_s, facets,
|
|
5916
|
-
limit: limit, master: true,
|
|
6240
|
+
limit: limit, master: true, **facet_opts,
|
|
5917
6241
|
# Master mode still has to name its application: the binding
|
|
5918
6242
|
# guard compares the client, not the posture, and an unnamed
|
|
5919
6243
|
# caller is refused once two applications are in play.
|
|
@@ -6063,7 +6387,7 @@ module Parse
|
|
|
6063
6387
|
def fetch_call_method_receiver(agent, klass, class_name, object_id)
|
|
6064
6388
|
scope = resolve_tenant_scope!(agent, class_name)
|
|
6065
6389
|
result = if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
6066
|
-
where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent)
|
|
6390
|
+
where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent, class_name)
|
|
6067
6391
|
rows = execute_find_via_direct(agent, class_name, where: where_id, limit: 1)
|
|
6068
6392
|
rows && rows.first
|
|
6069
6393
|
else
|
|
@@ -6196,6 +6520,27 @@ module Parse
|
|
|
6196
6520
|
module_function :compose_atlas_filter
|
|
6197
6521
|
|
|
6198
6522
|
# @api private
|
|
6523
|
+
# @api private
|
|
6524
|
+
# The fields an Atlas text search may run over when the caller named
|
|
6525
|
+
# none: the effective allowlist minus the always-keep system fields,
|
|
6526
|
+
# or nil when no allowlist applies (the wildcard is then harmless).
|
|
6527
|
+
def readable_atlas_text_fields(class_name)
|
|
6528
|
+
allowlist = Parse::Agent::MetadataRegistry.field_allowlist(class_name)
|
|
6529
|
+
return nil if allowlist.nil? || allowlist.empty?
|
|
6530
|
+
allowlist.map(&:to_s) - Parse::Agent::MetadataRegistry::ALWAYS_KEEP_FIELDS
|
|
6531
|
+
end
|
|
6532
|
+
|
|
6533
|
+
# @api private
|
|
6534
|
+
def refuse_empty_readable_text_fields!(class_name, fields)
|
|
6535
|
+
return unless fields.is_a?(Array) && fields.empty?
|
|
6536
|
+
raise Parse::Agent::AccessDenied.new(
|
|
6537
|
+
class_name,
|
|
6538
|
+
"No readable fields to text-search on class '#{class_name}' under this agent's field policy.",
|
|
6539
|
+
kind: :field_denied,
|
|
6540
|
+
allowed_fields: Parse::Agent::MetadataRegistry.field_allowlist(class_name)&.map(&:to_s),
|
|
6541
|
+
)
|
|
6542
|
+
end
|
|
6543
|
+
|
|
6199
6544
|
def assert_atlas_field_allowed!(class_name, field_name, kind:)
|
|
6200
6545
|
name = field_name.to_s
|
|
6201
6546
|
allowlist = Parse::Agent::MetadataRegistry.field_allowlist(class_name)
|
|
@@ -6251,6 +6596,10 @@ module Parse
|
|
|
6251
6596
|
# carry implementation details (toggle names, internal stage
|
|
6252
6597
|
# ordering) that aren't useful in an LLM tool-call response.
|
|
6253
6598
|
def invoke_atlas_search(op, class_name, query, opts, **extra)
|
|
6599
|
+
# Parse::AtlasSearch is loaded on first use, not with the agent. Load
|
|
6600
|
+
# it before the body runs so the rescue clauses below can resolve
|
|
6601
|
+
# its error classes.
|
|
6602
|
+
require_relative "../atlas_search"
|
|
6254
6603
|
case op
|
|
6255
6604
|
when :search
|
|
6256
6605
|
Parse::AtlasSearch.search(class_name, query, **opts)
|