parse-stack-next 5.7.5 → 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 +856 -0
- data/README.md +15 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +190 -14
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +318 -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 +290 -17
- 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/acl_scope.rb
CHANGED
|
@@ -266,6 +266,27 @@ module Parse
|
|
|
266
266
|
# * **`$facet`** — recursive: each facet branch is itself a
|
|
267
267
|
# pipeline; rewrite every branch independently.
|
|
268
268
|
#
|
|
269
|
+
# Each joined class is also held to its own Class-Level Permissions,
|
|
270
|
+
# the way Parse Server's include sub-query would be:
|
|
271
|
+
#
|
|
272
|
+
# * its `find` CLP is evaluated with Parse Server's branch
|
|
273
|
+
# semantics ({Parse::CLPScope.row_constraint_for!}). A denial
|
|
274
|
+
# refuses the whole join (fail closed). A grant that rests only
|
|
275
|
+
# on `readUserFields` / `pointerFields` adds the "row points at
|
|
276
|
+
# the requesting user" predicate to the join's leading `$match`
|
|
277
|
+
# (or to `restrictSearchWithMatch` for `$graphLookup`);
|
|
278
|
+
# * its protectedFields for the scope are removed by a
|
|
279
|
+
# {Parse::CLPScope.protected_strip_stage} placed right after that
|
|
280
|
+
# `$match`, before any caller stage of the sub-pipeline. Joined
|
|
281
|
+
# rows therefore never carry a protected field into the outer
|
|
282
|
+
# pipeline, whatever later stages reshape them, and a `_User`
|
|
283
|
+
# row keeps its own `email` only when its STORED `_id` is the
|
|
284
|
+
# requesting user.
|
|
285
|
+
#
|
|
286
|
+
# When `class_name:` names the queried class, each top-level `$facet`
|
|
287
|
+
# branch also starts with that class's strip stage: a branch nests the
|
|
288
|
+
# documents one level down, out of reach of the top-level strip.
|
|
289
|
+
#
|
|
269
290
|
# Returns a NEW Array; the input pipeline is not mutated.
|
|
270
291
|
# Master and nil-resolution pass through unchanged. Legacy
|
|
271
292
|
# (non-strict-role) empty-perms resolutions also pass through.
|
|
@@ -275,8 +296,12 @@ module Parse
|
|
|
275
296
|
#
|
|
276
297
|
# @param pipeline [Array<Hash>] the aggregation pipeline.
|
|
277
298
|
# @param resolution [Resolution, nil]
|
|
299
|
+
# @param class_name [String, nil] the queried class, for `$facet`
|
|
300
|
+
# branch stripping.
|
|
278
301
|
# @return [Array<Hash>] the rewritten pipeline.
|
|
279
|
-
|
|
302
|
+
# @raise [Parse::CLPScope::Denied] when a joined class refuses `find`
|
|
303
|
+
# for the scope.
|
|
304
|
+
def rewrite_pipeline(pipeline, resolution, class_name: nil)
|
|
280
305
|
return pipeline if pipeline.nil? || pipeline.empty?
|
|
281
306
|
return pipeline if resolution.nil? || resolution.master?
|
|
282
307
|
perms = resolution.permission_strings
|
|
@@ -301,7 +326,25 @@ module Parse
|
|
|
301
326
|
# this gate; the rewriter is the shared SDK-level layer so the
|
|
302
327
|
# mongo-direct path enforces it independent of whether an agent
|
|
303
328
|
# made the call.
|
|
304
|
-
|
|
329
|
+
ctx = JoinContext.new(acl_match, perms, resolution, clp_client_for(resolution))
|
|
330
|
+
pipeline.each_with_index.map do |stage, idx|
|
|
331
|
+
rewritten = rewrite_stage(stage, ctx)
|
|
332
|
+
next rewritten if class_name.nil?
|
|
333
|
+
strip_facet_branches(rewritten, class_name.to_s, ctx, pipeline[0...idx])
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
# Whether {.rewrite_pipeline} rewrites join stages for this
|
|
338
|
+
# resolution (and so places the protected-field strip at the head of
|
|
339
|
+
# every join). False for master, nil, and legacy empty-perms scopes.
|
|
340
|
+
#
|
|
341
|
+
# @param resolution [Resolution, nil]
|
|
342
|
+
# @return [Boolean]
|
|
343
|
+
def rewrites_joins?(resolution)
|
|
344
|
+
return false if resolution.nil? || resolution.master?
|
|
345
|
+
perms = resolution.permission_strings
|
|
346
|
+
strict = resolution.respond_to?(:strict_role?) && resolution.strict_role?
|
|
347
|
+
strict || !(perms.nil? || perms.empty?)
|
|
305
348
|
end
|
|
306
349
|
|
|
307
350
|
# Walk the result documents and redact every embedded sub-document
|
|
@@ -336,34 +379,70 @@ module Parse
|
|
|
336
379
|
|
|
337
380
|
private
|
|
338
381
|
|
|
382
|
+
# Per-call state shared by the join rewriters.
|
|
383
|
+
JoinContext = Struct.new(:acl_match, :perms, :resolution, :client)
|
|
384
|
+
|
|
385
|
+
# The client whose schema decides CLP for a resolution. A user-scoped
|
|
386
|
+
# client (from `become` / `session_client`) holds no master key and
|
|
387
|
+
# cannot read `/schemas`, so it falls back to the default client (nil).
|
|
388
|
+
# Mirrors {Parse::MongoDB.clp_client_for}.
|
|
389
|
+
def clp_client_for(resolution)
|
|
390
|
+
c = client_of(resolution)
|
|
391
|
+
return nil if c.nil?
|
|
392
|
+
return nil unless c.respond_to?(:master_key) && !c.master_key.to_s.empty?
|
|
393
|
+
c
|
|
394
|
+
end
|
|
395
|
+
|
|
339
396
|
# Apply the rewriter to a single pipeline stage. Operator-aware:
|
|
340
397
|
# only join-style stages are touched. Everything else passes
|
|
341
|
-
# through verbatim.
|
|
342
|
-
# CLP gate (Wave-3 TRACK-ACL-3) can challenge each
|
|
343
|
-
|
|
398
|
+
# through verbatim. The context is threaded down so the
|
|
399
|
+
# cross-class CLP gate (Wave-3 TRACK-ACL-3) can challenge each
|
|
400
|
+
# joined class.
|
|
401
|
+
def rewrite_stage(stage, ctx)
|
|
344
402
|
return stage unless stage.is_a?(Hash)
|
|
345
403
|
op_key, op_val = stage.first
|
|
346
404
|
case op_key.to_s
|
|
347
405
|
when "$lookup"
|
|
348
|
-
{ op_key => rewrite_lookup(op_val,
|
|
406
|
+
{ op_key => rewrite_lookup(op_val, ctx) }
|
|
349
407
|
when "$unionWith"
|
|
350
|
-
{ op_key => rewrite_union_with(op_val,
|
|
408
|
+
{ op_key => rewrite_union_with(op_val, ctx) }
|
|
351
409
|
when "$graphLookup"
|
|
352
|
-
{ op_key => rewrite_graph_lookup(op_val,
|
|
410
|
+
{ op_key => rewrite_graph_lookup(op_val, ctx) }
|
|
353
411
|
when "$facet"
|
|
354
|
-
{ op_key => rewrite_facet(op_val,
|
|
412
|
+
{ op_key => rewrite_facet(op_val, ctx) }
|
|
355
413
|
else
|
|
356
414
|
stage
|
|
357
415
|
end
|
|
358
416
|
end
|
|
359
417
|
|
|
360
|
-
# Cross-class CLP gate.
|
|
361
|
-
#
|
|
362
|
-
#
|
|
363
|
-
#
|
|
364
|
-
# `
|
|
365
|
-
#
|
|
366
|
-
|
|
418
|
+
# Cross-class CLP gate for a joined class. Applies the hard
|
|
419
|
+
# internal-collection floor, then evaluates the joined class's `find`
|
|
420
|
+
# CLP with Parse Server's branch semantics. Raises
|
|
421
|
+
# {Parse::CLPScope::Denied} when the scope cannot `find` rows of
|
|
422
|
+
# `target` (including an unresolvable CLP: fail closed). Returns the
|
|
423
|
+
# `$match` predicate the joined rows must satisfy: the ACL predicate,
|
|
424
|
+
# ANDed with the pointer-ownership predicate when the grant rests on
|
|
425
|
+
# `readUserFields` / `pointerFields`.
|
|
426
|
+
#
|
|
427
|
+
# Master mode is short-circuited in {.rewrite_pipeline}, so reaching
|
|
428
|
+
# this helper means `perms` is a real claim set.
|
|
429
|
+
def join_predicate!(target, ctx)
|
|
430
|
+
acl_predicate = ctx.acl_match["$match"]
|
|
431
|
+
return acl_predicate if target.nil? || target.to_s.empty?
|
|
432
|
+
target_str = target.to_s
|
|
433
|
+
assert_join_target_permitted!(target_str, ctx.perms, client: ctx.client)
|
|
434
|
+
pointer_fields = Parse::CLPScope.row_constraint_for!(
|
|
435
|
+
target_str, :find, ctx.resolution, client: ctx.client,
|
|
436
|
+
)
|
|
437
|
+
return acl_predicate if pointer_fields.nil?
|
|
438
|
+
ownership = Parse::CLPScope.pointer_fields_predicate(pointer_fields, ctx.resolution.user_id)
|
|
439
|
+
{ "$and" => [acl_predicate, ownership] }
|
|
440
|
+
end
|
|
441
|
+
|
|
442
|
+
# Internal-collection floor plus the `permits?` CLP gate for a joined
|
|
443
|
+
# class. Raises {Parse::PipelineSecurity::Error} for an internal
|
|
444
|
+
# collection and {Parse::CLPScope::Denied} when CLP refuses `find`.
|
|
445
|
+
def assert_join_target_permitted!(target, perms, client: nil)
|
|
367
446
|
return if target.nil?
|
|
368
447
|
target_str = target.to_s
|
|
369
448
|
return if target_str.empty?
|
|
@@ -378,22 +457,53 @@ module Parse
|
|
|
378
457
|
# admitting the SDK data classes (`_User`/`_Role`/`_Installation`/
|
|
379
458
|
# `_Session`), which then face the per-scope CLP `find` gate.
|
|
380
459
|
Parse::PipelineSecurity.assert_collection_allowed!(target_str)
|
|
381
|
-
return if Parse::CLPScope.permits?(target_str, :find, perms)
|
|
460
|
+
return if Parse::CLPScope.permits?(target_str, :find, perms, client: client)
|
|
382
461
|
raise Parse::CLPScope::Denied.new(
|
|
383
462
|
target_str, :find,
|
|
384
463
|
"Joined class '#{target_str}' refuses :find for current scope.",
|
|
385
464
|
)
|
|
386
465
|
end
|
|
387
466
|
|
|
388
|
-
|
|
467
|
+
# The stages that lead a join's sub-pipeline: the row filter, then
|
|
468
|
+
# the joined class's protected-field strip (when it has any).
|
|
469
|
+
def join_head_stages!(target, ctx)
|
|
470
|
+
head = [{ "$match" => join_predicate!(target, ctx) }]
|
|
471
|
+
strip = strip_stage_for(target, ctx)
|
|
472
|
+
head << strip if strip
|
|
473
|
+
head
|
|
474
|
+
end
|
|
475
|
+
|
|
476
|
+
def strip_stage_for(target, ctx, user_id: ctx.resolution.user_id)
|
|
477
|
+
return nil if target.nil? || target.to_s.empty?
|
|
478
|
+
set = Parse::CLPScope.protected_fields_for(target.to_s, ctx.perms, client: ctx.client)
|
|
479
|
+
Parse::CLPScope.protected_strip_stage(set, class_name: target.to_s, user_id: user_id)
|
|
480
|
+
end
|
|
481
|
+
|
|
482
|
+
# Prepend the queried class's strip stage to every branch of a
|
|
483
|
+
# top-level `$facet`. The `_User` self exemption is kept only when no
|
|
484
|
+
# earlier stage can have rewritten `_id`.
|
|
485
|
+
def strip_facet_branches(stage, class_name, ctx, preceding)
|
|
486
|
+
return stage unless stage.is_a?(Hash)
|
|
487
|
+
op_key, op_val = stage.first
|
|
488
|
+
return stage unless op_key.to_s == "$facet" && op_val.is_a?(Hash)
|
|
489
|
+
stable = Parse::PipelineSecurity.identity_preserving?(preceding)
|
|
490
|
+
strip = strip_stage_for(class_name, ctx, user_id: stable ? ctx.resolution.user_id : nil)
|
|
491
|
+
return stage if strip.nil?
|
|
492
|
+
branches = op_val.each_with_object({}) do |(name, branch), out|
|
|
493
|
+
out[name] = branch.is_a?(Array) ? [strip] + branch : branch
|
|
494
|
+
end
|
|
495
|
+
{ op_key => branches }
|
|
496
|
+
end
|
|
497
|
+
|
|
498
|
+
def rewrite_lookup(spec, ctx)
|
|
389
499
|
# String shorthand `{$lookup: "Collection"}` is not a real
|
|
390
500
|
# Mongo form; defensively leave it alone.
|
|
391
501
|
return spec unless spec.is_a?(Hash)
|
|
392
502
|
# Gate FIRST so a CLP-denied join is refused before the
|
|
393
503
|
# rewriter spends work rebuilding the sub-pipeline. `from`
|
|
394
|
-
# accepts string or symbol
|
|
504
|
+
# accepts string or symbol; the gate normalizes it.
|
|
395
505
|
target = spec["from"] || spec[:from]
|
|
396
|
-
|
|
506
|
+
head = join_head_stages!(target, ctx)
|
|
397
507
|
spec = spec.dup
|
|
398
508
|
existing_pipeline = spec["pipeline"] || spec[:pipeline] || []
|
|
399
509
|
# Walk the sub-pipeline recursively so nested $lookup /
|
|
@@ -402,69 +512,77 @@ module Parse
|
|
|
402
512
|
# `perms` set. (Mongo evaluates the sub-pipeline in the
|
|
403
513
|
# joined collection's context, but the requesting session is
|
|
404
514
|
# unchanged; permissions don't elevate by traversing a join.)
|
|
405
|
-
rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s,
|
|
406
|
-
|
|
407
|
-
spec["pipeline"] = new_pipeline
|
|
515
|
+
rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, ctx) }
|
|
516
|
+
spec["pipeline"] = head + rewritten_inner
|
|
408
517
|
spec.delete(:pipeline) # symbol form was promoted to string form
|
|
409
518
|
spec
|
|
410
519
|
end
|
|
411
520
|
|
|
412
|
-
def rewrite_union_with(spec,
|
|
521
|
+
def rewrite_union_with(spec, ctx)
|
|
413
522
|
# `$unionWith` accepts either a String (collection name only)
|
|
414
|
-
# or a Hash `{coll:, pipeline:}`.
|
|
415
|
-
# either shape so the CLP gate fires before the String
|
|
416
|
-
# upgrade
|
|
523
|
+
# or a Hash `{coll:, pipeline:}`. Resolve the target from
|
|
524
|
+
# either shape so the CLP gate fires before the String to Hash
|
|
525
|
+
# upgrade, denying access to the joined class BEFORE we go
|
|
417
526
|
# to the trouble of building out an upgraded sub-pipeline.
|
|
418
527
|
target = if spec.is_a?(String)
|
|
419
528
|
spec
|
|
420
529
|
elsif spec.is_a?(Hash)
|
|
421
530
|
spec["coll"] || spec[:coll]
|
|
422
531
|
end
|
|
423
|
-
|
|
532
|
+
return spec unless spec.is_a?(String) || spec.is_a?(Hash)
|
|
533
|
+
head = join_head_stages!(target, ctx)
|
|
424
534
|
|
|
425
535
|
if spec.is_a?(String)
|
|
426
|
-
return { "coll" => spec, "pipeline" =>
|
|
536
|
+
return { "coll" => spec, "pipeline" => head }
|
|
427
537
|
end
|
|
428
|
-
return spec unless spec.is_a?(Hash)
|
|
429
538
|
spec = spec.dup
|
|
430
539
|
existing_pipeline = spec["pipeline"] || spec[:pipeline] || []
|
|
431
|
-
rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s,
|
|
432
|
-
spec["pipeline"] =
|
|
540
|
+
rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, ctx) }
|
|
541
|
+
spec["pipeline"] = head + rewritten_inner
|
|
433
542
|
spec.delete(:pipeline)
|
|
434
543
|
spec
|
|
435
544
|
end
|
|
436
545
|
|
|
437
|
-
def rewrite_graph_lookup(spec,
|
|
546
|
+
def rewrite_graph_lookup(spec, ctx)
|
|
438
547
|
return spec unless spec.is_a?(Hash)
|
|
439
|
-
# Same CLP gate, same reasoning
|
|
548
|
+
# Same CLP gate, same reasoning: $graphLookup reads from a
|
|
440
549
|
# different collection in the same session's authority.
|
|
441
550
|
target = spec["from"] || spec[:from]
|
|
442
|
-
|
|
551
|
+
predicate = join_predicate!(target, ctx)
|
|
552
|
+
# `$graphLookup` returns whole joined documents and has no
|
|
553
|
+
# sub-pipeline to strip protected fields in. Refuse it when the
|
|
554
|
+
# joined class has any for this scope.
|
|
555
|
+
if strip_stage_for(target, ctx)
|
|
556
|
+
raise Parse::CLPScope::Denied.new(
|
|
557
|
+
target.to_s, :find,
|
|
558
|
+
"$graphLookup into '#{target}' would return protected fields for the " \
|
|
559
|
+
"current scope; use $lookup, which strips them.",
|
|
560
|
+
)
|
|
561
|
+
end
|
|
443
562
|
spec = spec.dup
|
|
444
563
|
# `$graphLookup` doesn't accept a sub-pipeline. Its filter hook
|
|
445
564
|
# is `restrictSearchWithMatch`, which is a $match-predicate (no
|
|
446
565
|
# `$match` wrapper). Combine with any existing restriction via
|
|
447
566
|
# `$and`.
|
|
448
|
-
acl_predicate = acl_match["$match"]
|
|
449
567
|
existing = spec["restrictSearchWithMatch"] || spec[:restrictSearchWithMatch]
|
|
450
568
|
combined = if existing.nil? || (existing.respond_to?(:empty?) && existing.empty?)
|
|
451
|
-
|
|
569
|
+
predicate
|
|
452
570
|
else
|
|
453
|
-
{ "$and" => [existing,
|
|
571
|
+
{ "$and" => [existing, predicate] }
|
|
454
572
|
end
|
|
455
573
|
spec["restrictSearchWithMatch"] = combined
|
|
456
574
|
spec.delete(:restrictSearchWithMatch)
|
|
457
575
|
spec
|
|
458
576
|
end
|
|
459
577
|
|
|
460
|
-
def rewrite_facet(spec,
|
|
578
|
+
def rewrite_facet(spec, ctx)
|
|
461
579
|
return spec unless spec.is_a?(Hash)
|
|
462
580
|
spec.each_with_object({}) do |(branch_name, branch_pipeline), out|
|
|
463
581
|
out[branch_name] = if branch_pipeline.is_a?(Array)
|
|
464
|
-
# Recurse with the same perms
|
|
582
|
+
# Recurse with the same perms: facet branches are
|
|
465
583
|
# evaluated in the requesting session's authority, not
|
|
466
584
|
# elevated.
|
|
467
|
-
branch_pipeline.map { |s| rewrite_stage(s,
|
|
585
|
+
branch_pipeline.map { |s| rewrite_stage(s, ctx) }
|
|
468
586
|
else
|
|
469
587
|
branch_pipeline
|
|
470
588
|
end
|
|
Binary file
|
|
@@ -138,6 +138,10 @@ module Parse
|
|
|
138
138
|
# `^foo.*` is not).
|
|
139
139
|
REDOS_NESTED_QUANTIFIER_RE = /\([^)]*[+*][^)]*\)[+*?]/.freeze
|
|
140
140
|
|
|
141
|
+
# Fiber-storage key holding the class whose field names are being
|
|
142
|
+
# resolved; see {#translate}.
|
|
143
|
+
CLASS_SCOPE_KEY = :parse_agent_constraint_class
|
|
144
|
+
|
|
141
145
|
# Translate JSON constraints to Parse query format.
|
|
142
146
|
# Validates all operators against the security whitelist.
|
|
143
147
|
#
|
|
@@ -153,9 +157,38 @@ module Parse
|
|
|
153
157
|
# — continues to parse as a single positional Hash under Ruby 3+
|
|
154
158
|
# kwargs separation. Adding a kwarg would have turned the same call
|
|
155
159
|
# into "empty kwargs + missing positional arg."
|
|
156
|
-
|
|
160
|
+
#
|
|
161
|
+
# @param class_name [String, nil] the class the constraints apply to.
|
|
162
|
+
# When given, field keys are resolved the way the field-policy check
|
|
163
|
+
# resolves them ({Parse::Agent::MetadataRegistry.wire_field_names}):
|
|
164
|
+
# a declared `field:` name such as `PublicText` is kept exactly, so the
|
|
165
|
+
# column that was checked is the column that is queried. Embedded
|
|
166
|
+
# `$inQuery`/`$select` clauses resolve against their own className.
|
|
167
|
+
def translate(constraints, agent = nil, class_name = nil)
|
|
157
168
|
return {} if constraints.nil? || constraints.empty?
|
|
169
|
+
previous = Fiber[CLASS_SCOPE_KEY]
|
|
170
|
+
Fiber[CLASS_SCOPE_KEY] = class_name&.to_s
|
|
171
|
+
begin
|
|
172
|
+
translate_in_scope(constraints, agent)
|
|
173
|
+
ensure
|
|
174
|
+
Fiber[CLASS_SCOPE_KEY] = previous
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Check if constraints are valid without raising.
|
|
179
|
+
#
|
|
180
|
+
# @param constraints [Hash] the query constraints
|
|
181
|
+
# @return [Boolean] true if valid, false otherwise
|
|
182
|
+
def valid?(constraints)
|
|
183
|
+
translate(constraints)
|
|
184
|
+
true
|
|
185
|
+
rescue ConstraintSecurityError, InvalidOperatorError
|
|
186
|
+
false
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
private
|
|
158
190
|
|
|
191
|
+
def translate_in_scope(constraints, agent)
|
|
159
192
|
raise InvalidOperatorError.new(
|
|
160
193
|
"Constraints must be a Hash, got #{constraints.class}",
|
|
161
194
|
operator: nil,
|
|
@@ -178,19 +211,6 @@ module Parse
|
|
|
178
211
|
end
|
|
179
212
|
end
|
|
180
213
|
|
|
181
|
-
# Check if constraints are valid without raising.
|
|
182
|
-
#
|
|
183
|
-
# @param constraints [Hash] the query constraints
|
|
184
|
-
# @return [Boolean] true if valid, false otherwise
|
|
185
|
-
def valid?(constraints)
|
|
186
|
-
translate(constraints)
|
|
187
|
-
true
|
|
188
|
-
rescue ConstraintSecurityError, InvalidOperatorError
|
|
189
|
-
false
|
|
190
|
-
end
|
|
191
|
-
|
|
192
|
-
private
|
|
193
|
-
|
|
194
214
|
# Translate a single value, handling nested operators
|
|
195
215
|
#
|
|
196
216
|
# @param value [Object] the value to translate
|
|
@@ -305,7 +325,7 @@ module Parse
|
|
|
305
325
|
# disabling the per-agent class filter on every nested
|
|
306
326
|
# cross-class hop. Keep this call POSITIONAL.
|
|
307
327
|
if embedded_where.is_a?(Hash)
|
|
308
|
-
translated_where = translate(embedded_where, agent)
|
|
328
|
+
translated_where = translate(embedded_where, agent, embedded_class_name)
|
|
309
329
|
new_val = val.dup
|
|
310
330
|
if op == "$select" || op == "$dontSelect"
|
|
311
331
|
query_part = new_val["query"].transform_keys(&:to_s)
|
|
@@ -542,6 +562,13 @@ module Parse
|
|
|
542
562
|
def columnize(field)
|
|
543
563
|
return field if field.start_with?("_") # Preserve special fields like _User
|
|
544
564
|
|
|
565
|
+
class_name = Fiber[CLASS_SCOPE_KEY]
|
|
566
|
+
if class_name && !class_name.empty? && defined?(Parse::Agent::MetadataRegistry)
|
|
567
|
+
root, rest = field.to_s.split(".", 2)
|
|
568
|
+
wire = Parse::Agent::MetadataRegistry.wire_field_names(class_name, [root]).first || root
|
|
569
|
+
return rest ? "#{wire}.#{rest}" : wire
|
|
570
|
+
end
|
|
571
|
+
|
|
545
572
|
# Convert snake_case to camelCase
|
|
546
573
|
field.to_s.gsub(/_([a-z])/) { ::Regexp.last_match(1).upcase }
|
|
547
574
|
.sub(/^([A-Z])/) { ::Regexp.last_match(1).downcase }
|
data/lib/parse/agent/describe.rb
CHANGED
|
@@ -298,7 +298,9 @@ module Parse
|
|
|
298
298
|
# the wire-format field name Array so the output reads identically to
|
|
299
299
|
# the schema-enriched `get_schema` echo.
|
|
300
300
|
def class_field_allowlist(class_name)
|
|
301
|
-
|
|
301
|
+
# Effective allowlist for THIS agent: the class ceiling narrowed by
|
|
302
|
+
# its `fields:` policy.
|
|
303
|
+
list = Parse::Agent::FieldPolicy.with(self) { Parse::Agent::MetadataRegistry.field_allowlist(class_name) }
|
|
302
304
|
list && list.any? ? list.dup : nil
|
|
303
305
|
end
|
|
304
306
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
class Agent
|
|
6
|
+
# Per-agent data-field naming mode for tool output.
|
|
7
|
+
#
|
|
8
|
+
# `Parse::Agent.new(field_names: :server)` asks for data fields in the
|
|
9
|
+
# exact names Parse returns or the model declares through its
|
|
10
|
+
# `field_map` (`createdAt`, `totalPlays`, `ExternalID`), with no
|
|
11
|
+
# snake_case conversion anywhere a tool would otherwise apply one.
|
|
12
|
+
# `:default` (or omitting the option) keeps every tool's existing output.
|
|
13
|
+
#
|
|
14
|
+
# Only data-field keys are affected. MCP protocol keys and SDK envelope
|
|
15
|
+
# keys (`chunks`, `documents`, `object_id`, `next_call`, ...) keep their
|
|
16
|
+
# contracts, and "server names" never means raw MongoDB storage columns
|
|
17
|
+
# (`_p_author`, `_rperm`, `_session_token`). Naming is presentation only:
|
|
18
|
+
# every access check (ACL/CLP, protectedFields, class and per-agent field
|
|
19
|
+
# policies) resolves against canonical field identities before output.
|
|
20
|
+
#
|
|
21
|
+
# Like {FieldPolicy}, the mode is scoped fiber-locally around each tool
|
|
22
|
+
# call by {Parse::Agent::Tools.invoke}, so concurrent agents with
|
|
23
|
+
# different modes never see each other's setting and nothing global is
|
|
24
|
+
# mutated.
|
|
25
|
+
module FieldNames
|
|
26
|
+
SCOPE_KEY = :parse_agent_field_names_scope
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
# Run the block with `agent`'s naming mode in effect.
|
|
31
|
+
def with(agent)
|
|
32
|
+
previous = Fiber[SCOPE_KEY]
|
|
33
|
+
Fiber[SCOPE_KEY] = agent
|
|
34
|
+
yield
|
|
35
|
+
ensure
|
|
36
|
+
Fiber[SCOPE_KEY] = previous
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Symbol] `:server` or `:default` for the agent whose tool is
|
|
40
|
+
# executing; `:default` outside a tool call.
|
|
41
|
+
def current_mode
|
|
42
|
+
agent = Fiber[SCOPE_KEY]
|
|
43
|
+
mode = agent.respond_to?(:field_names_mode) ? agent.field_names_mode : nil
|
|
44
|
+
mode || :default
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# @return [Boolean] true when the executing agent asked for server names.
|
|
48
|
+
def server?
|
|
49
|
+
current_mode == :server
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
class Agent
|
|
6
|
+
# Per-agent field narrowing.
|
|
7
|
+
#
|
|
8
|
+
# A class's `agent_fields` declaration is the CEILING: the most any agent
|
|
9
|
+
# may read. A `Parse::Agent.new(fields: { "Post" => [:title, :status] })`
|
|
10
|
+
# policy NARROWS that ceiling for one agent (one MCP deployment), so a
|
|
11
|
+
# user-facing assistant and an analytics endpoint in the same process can
|
|
12
|
+
# expose different subsets of the same model. A policy can never widen
|
|
13
|
+
# past the ceiling, and a sub-agent's policy intersects its parent's.
|
|
14
|
+
#
|
|
15
|
+
# The effective allowlist is resolved by
|
|
16
|
+
# {Parse::Agent::MetadataRegistry.field_allowlist}, which every
|
|
17
|
+
# enforcement point already calls (projection, `where:`/`keys:` checks,
|
|
18
|
+
# aggregation pipelines, Atlas Search fields, schema output, exports,
|
|
19
|
+
# `semantic_search` chunk text). This module supplies the narrowing for
|
|
20
|
+
# the agent whose tool is currently executing: {Parse::Agent::Tools.invoke}
|
|
21
|
+
# wraps every tool call in {.with}, so the policy applies without
|
|
22
|
+
# threading the agent through each helper.
|
|
23
|
+
#
|
|
24
|
+
# The scope lives in fiber storage (`Fiber[]`), which child fibers and
|
|
25
|
+
# threads inherit when they are created. Concurrent requests on
|
|
26
|
+
# different threads never see each other's policy, and work a custom
|
|
27
|
+
# tool hands to a thread or fiber it starts stays narrowed.
|
|
28
|
+
module FieldPolicy
|
|
29
|
+
SCOPE_KEY = :parse_agent_field_policy_scope
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Run the block with `agent`'s field narrowing in effect.
|
|
34
|
+
#
|
|
35
|
+
# @param agent [Parse::Agent, nil]
|
|
36
|
+
# @return the block's value
|
|
37
|
+
#
|
|
38
|
+
# Scopes nest: a tool that builds another agent and invokes it (even one
|
|
39
|
+
# constructed without `parent:`) runs under BOTH policies, so the inner
|
|
40
|
+
# call can only narrow further, never escape the outer agent's policy.
|
|
41
|
+
def with(agent)
|
|
42
|
+
previous = Fiber[SCOPE_KEY]
|
|
43
|
+
Fiber[SCOPE_KEY] = (previous || []) + [agent]
|
|
44
|
+
yield
|
|
45
|
+
ensure
|
|
46
|
+
Fiber[SCOPE_KEY] = previous
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# @return [Parse::Agent, nil] the innermost agent whose tool is executing.
|
|
50
|
+
def current_agent
|
|
51
|
+
Fiber[SCOPE_KEY]&.last
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Wire-format field names the current agent narrows `class_name` to, or
|
|
55
|
+
# nil when the agent places no narrowing on that class (or no agent is
|
|
56
|
+
# in scope).
|
|
57
|
+
#
|
|
58
|
+
# @param class_name [String]
|
|
59
|
+
# @return [Array<String>, nil]
|
|
60
|
+
def narrowing_for(class_name)
|
|
61
|
+
stack = Fiber[SCOPE_KEY]
|
|
62
|
+
return nil if stack.nil? || stack.empty?
|
|
63
|
+
result = nil
|
|
64
|
+
stack.uniq.each do |agent|
|
|
65
|
+
next unless agent.respond_to?(:field_narrowing_for)
|
|
66
|
+
names = agent.field_narrowing_for(class_name)
|
|
67
|
+
next if names.nil?
|
|
68
|
+
result = result ? (result & names) : names
|
|
69
|
+
end
|
|
70
|
+
result
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|