parse-stack-next 5.5.4 → 5.5.5
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 +8 -6
- data/README.md +26 -13
- data/bin/parse-console +9 -1
- data/docs/TEST_SERVER.md +115 -238
- data/docs/mcp_guide.md +1 -1
- data/docs/mongodb_index_optimization_guide.md +3 -2
- data/docs/usage_guide.md +1 -1
- data/docs/yard-template/default/fulldoc/html/css/common.css +52 -9
- data/docs/yard-template/default/fulldoc/html/css/full_list.css +40 -13
- data/lib/parse/agent/constraint_translator.rb +18 -18
- data/lib/parse/agent/errors.rb +29 -7
- data/lib/parse/agent/metadata_dsl.rb +6 -6
- data/lib/parse/agent/tools.rb +250 -59
- data/lib/parse/agent.rb +42 -30
- data/lib/parse/api/aggregate.rb +3 -3
- data/lib/parse/api/cloud_functions.rb +19 -10
- data/lib/parse/api/objects.rb +8 -8
- data/lib/parse/api/users.rb +9 -9
- data/lib/parse/atlas_search/session.rb +34 -34
- data/lib/parse/atlas_search.rb +243 -110
- data/lib/parse/client/body_builder.rb +10 -10
- data/lib/parse/client/logging.rb +5 -2
- data/lib/parse/client/profiling.rb +5 -2
- data/lib/parse/client/protocol.rb +1 -1
- data/lib/parse/client/url_redaction.rb +94 -0
- data/lib/parse/client.rb +43 -28
- data/lib/parse/embeddings/image_fetch.rb +6 -1
- data/lib/parse/embeddings/voyage.rb +16 -17
- data/lib/parse/live_query/client.rb +7 -7
- data/lib/parse/live_query/subscription.rb +1 -1
- data/lib/parse/lock.rb +1 -1
- data/lib/parse/lock_backend.rb +118 -2
- data/lib/parse/model/acl.rb +24 -24
- data/lib/parse/model/classes/job_schedule.rb +8 -8
- data/lib/parse/model/classes/job_status.rb +9 -9
- data/lib/parse/model/classes/role.rb +49 -49
- data/lib/parse/model/classes/session.rb +2 -2
- data/lib/parse/model/classes/user.rb +66 -66
- data/lib/parse/model/core/builder.rb +7 -7
- data/lib/parse/model/core/create_lock.rb +1 -1
- data/lib/parse/model/core/properties.rb +4 -4
- data/lib/parse/model/file.rb +57 -16
- data/lib/parse/model/model.rb +19 -19
- data/lib/parse/model/object.rb +38 -38
- data/lib/parse/model/pointer.rb +4 -4
- data/lib/parse/model/push.rb +5 -5
- data/lib/parse/mongodb.rb +84 -26
- data/lib/parse/pipeline_security.rb +2 -2
- data/lib/parse/query/constraints.rb +38 -38
- data/lib/parse/query.rb +151 -75
- data/lib/parse/retrieval/reranker/cohere.rb +30 -0
- data/lib/parse/schema.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +23 -10
- data/lib/parse/two_factor_auth/user_extension.rb +25 -25
- data/lib/parse/webhooks/payload.rb +35 -35
- data/lib/parse/webhooks/registration.rb +2 -2
- data/lib/parse/webhooks/replay_protection.rb +16 -16
- data/lib/parse/webhooks.rb +11 -11
- data/parse-stack-next.gemspec +19 -1
- metadata +2 -38
- data/.bundle/config +0 -5
- data/.env.sample +0 -138
- data/.env.test +0 -10
- data/.github/ISSUE_TEMPLATE/bug_report.yml +0 -105
- data/.github/ISSUE_TEMPLATE/feature_request.yml +0 -67
- data/.github/dependabot.yml +0 -13
- data/.github/workflows/codeql.yml +0 -44
- data/.github/workflows/docs.yml +0 -39
- data/.github/workflows/release.yml +0 -43
- data/.github/workflows/ruby.yml +0 -38
- data/.gitignore +0 -56
- data/.ruby-version +0 -1
- data/.solargraph.yml +0 -22
- data/.vscode/settings.json +0 -3
- data/.yardopts +0 -19
- data/Gemfile +0 -43
- data/Gemfile.lock +0 -198
- data/Makefile +0 -63
- data/Rakefile +0 -825
- data/config/parse-config.json +0 -12
- data/scripts/debug-ips.js +0 -35
- data/scripts/docker/Dockerfile.parse +0 -17
- data/scripts/docker/atlas-init.js +0 -284
- data/scripts/docker/docker-compose.atlas.yml +0 -80
- data/scripts/docker/docker-compose.test.yml +0 -159
- data/scripts/docker/docker-compose.verifyemail.yml +0 -4
- data/scripts/docker/mongo-init.js +0 -21
- data/scripts/docker/preflight.sh +0 -76
- data/scripts/eval_mcp_with_lm_studio.rb +0 -274
- data/scripts/start-parse.sh +0 -154
- data/scripts/start_mcp_server.rb +0 -78
- data/scripts/test_server_connection.rb +0 -82
- data/scripts/vector_prototype/create_vector_index.js +0 -105
- data/scripts/vector_prototype/fetch_embeddings.py +0 -241
- data/scripts/vector_prototype/fixture_manifest.json +0 -9
- data/scripts/vector_prototype/query_prototype.rb +0 -84
- data/scripts/vector_prototype/run.sh +0 -34
data/lib/parse/agent/tools.rb
CHANGED
|
@@ -76,9 +76,9 @@ module Parse
|
|
|
76
76
|
}.freeze
|
|
77
77
|
|
|
78
78
|
# Per-tool clamps for Atlas Search result counts. The hard cap
|
|
79
|
-
# (
|
|
79
|
+
# (`ATLAS_LIMIT_MAX`) keeps response sizes predictable when an
|
|
80
80
|
# LLM agent asks for "everything"; the default keeps token
|
|
81
|
-
# spend reasonable when an agent forgets to set
|
|
81
|
+
# spend reasonable when an agent forgets to set `limit:`.
|
|
82
82
|
ATLAS_LIMIT_DEFAULT = 10
|
|
83
83
|
ATLAS_LIMIT_MAX = 20
|
|
84
84
|
|
|
@@ -1071,12 +1071,12 @@ module Parse
|
|
|
1071
1071
|
# Timeout.timeout. Must be >= 1 (a non-positive value raises
|
|
1072
1072
|
# ArgumentError, since Timeout.timeout(0) would disable the bound).
|
|
1073
1073
|
# @param handler [Proc] lambda(agent, **args) -> Hash (required)
|
|
1074
|
-
# @param client_safe [Boolean] when
|
|
1074
|
+
# @param client_safe [Boolean] when `true`, the tool is dispatchable
|
|
1075
1075
|
# from a client-mode agent (one whose client has no master_key).
|
|
1076
|
-
# Default
|
|
1076
|
+
# Default `false` — registered tools are master-key-only unless
|
|
1077
1077
|
# the author explicitly declares them safe for session-token
|
|
1078
1078
|
# contexts. The handler is responsible for routing through
|
|
1079
|
-
#
|
|
1079
|
+
# `agent.client` with `agent.session_token` rather than touching
|
|
1080
1080
|
# the master key directly.
|
|
1081
1081
|
# @raise [ArgumentError] when required kwargs are missing or permission is invalid
|
|
1082
1082
|
#
|
|
@@ -1248,8 +1248,18 @@ module Parse
|
|
|
1248
1248
|
|
|
1249
1249
|
if entry
|
|
1250
1250
|
with_timeout(sym) { entry[:handler].call(agent, **kwargs) }
|
|
1251
|
-
|
|
1251
|
+
elsif Tools.respond_to?(sym, true)
|
|
1252
1252
|
Tools.send(sym, agent, **kwargs)
|
|
1253
|
+
else
|
|
1254
|
+
# Recognized tool name (it passed the tier/gate checks in
|
|
1255
|
+
# Parse::Agent#execute to get here) but no handler ships for
|
|
1256
|
+
# it — the built-in write/admin CRUD tools (create_object,
|
|
1257
|
+
# update_object, delete_object, create_class, delete_class)
|
|
1258
|
+
# are declared in the tiers without an implementation. Raise a
|
|
1259
|
+
# clear typed error instead of the bare NoMethodError that
|
|
1260
|
+
# `Tools.send` would otherwise produce (which collapses to an
|
|
1261
|
+
# opaque "internal error" on the wire).
|
|
1262
|
+
raise Parse::Agent::NotImplemented.new(sym)
|
|
1253
1263
|
end
|
|
1254
1264
|
end
|
|
1255
1265
|
|
|
@@ -1316,9 +1326,9 @@ module Parse
|
|
|
1316
1326
|
# natively authorizes (ACL + CLP + protectedFields).
|
|
1317
1327
|
# * Built-in mutation tools listed in CLIENT_SAFE_MUTATION_TOOLS —
|
|
1318
1328
|
# same REST-native authorization. The caller is responsible for
|
|
1319
|
-
# additionally checking the per-agent
|
|
1329
|
+
# additionally checking the per-agent `allow_mutations` gate;
|
|
1320
1330
|
# this predicate reports REST-safety only.
|
|
1321
|
-
# * Custom tools registered with
|
|
1331
|
+
# * Custom tools registered with `client_safe: true` — the
|
|
1322
1332
|
# registering author has declared the handler safe for
|
|
1323
1333
|
# client-mode dispatch.
|
|
1324
1334
|
#
|
|
@@ -1393,6 +1403,17 @@ module Parse
|
|
|
1393
1403
|
|
|
1394
1404
|
defs = allowed_tools.filter_map do |tool_name|
|
|
1395
1405
|
sym = tool_name.to_sym
|
|
1406
|
+
# Invariant: never advertise a tool we can't actually dispatch.
|
|
1407
|
+
# The declared-but-unimplemented write/admin CRUD tools
|
|
1408
|
+
# (create_object/update_object/delete_object/create_class/
|
|
1409
|
+
# delete_class) are members of the write/admin permission tiers
|
|
1410
|
+
# but ship no handler — advertising one in tools/list only to
|
|
1411
|
+
# have it raise NotImplemented on call is worse than omitting it.
|
|
1412
|
+
# A tool is dispatchable iff it's a registered handler or a
|
|
1413
|
+
# builtin Tools method. (Today these also lack a TOOL_DEFINITIONS
|
|
1414
|
+
# entry, so they'd drop out anyway; this makes the guarantee
|
|
1415
|
+
# explicit and future-proof.)
|
|
1416
|
+
next unless registered_defs.key?(sym) || Tools.respond_to?(sym, true)
|
|
1396
1417
|
registered_defs[sym] || TOOL_DEFINITIONS[sym]
|
|
1397
1418
|
end
|
|
1398
1419
|
|
|
@@ -1504,12 +1525,12 @@ module Parse
|
|
|
1504
1525
|
# agent_method names. Same shape as Ruby method names with a length cap.
|
|
1505
1526
|
METHOD_NAME_RE = /\A[A-Za-z_][A-Za-z0-9_]{0,127}[!?=]?\z/.freeze
|
|
1506
1527
|
|
|
1507
|
-
# Keys that are NEVER permitted as
|
|
1508
|
-
# regardless of the method's declared
|
|
1528
|
+
# Keys that are NEVER permitted as `arguments` to `call_method`,
|
|
1529
|
+
# regardless of the method's declared `permitted_keys`. These
|
|
1509
1530
|
# reference Parse-internal columns (auth/credential state, ACL,
|
|
1510
1531
|
# identifiers managed by Parse Server) and have no legitimate
|
|
1511
1532
|
# use case being set by an LLM through a wrapper method that
|
|
1512
|
-
# splats its arguments with
|
|
1533
|
+
# splats its arguments with `**`.
|
|
1513
1534
|
CALL_METHOD_DENIED_KEYS = %i[
|
|
1514
1535
|
_hashed_password _password_history
|
|
1515
1536
|
authData auth_data _auth_data
|
|
@@ -1521,16 +1542,16 @@ module Parse
|
|
|
1521
1542
|
className __type
|
|
1522
1543
|
].freeze
|
|
1523
1544
|
|
|
1524
|
-
# Framework-reserved keys that are always permitted in
|
|
1525
|
-
# regardless of the method's
|
|
1526
|
-
# gated separately by
|
|
1545
|
+
# Framework-reserved keys that are always permitted in `arguments`
|
|
1546
|
+
# regardless of the method's `permitted_keys` list. `dry_run` is
|
|
1547
|
+
# gated separately by `supports_dry_run` on the method definition.
|
|
1527
1548
|
# Reserved kwargs the agent dispatcher may inject into an
|
|
1528
|
-
#
|
|
1529
|
-
# declared
|
|
1549
|
+
# `agent_method` body. `dry_run` is forwarded when the method
|
|
1550
|
+
# declared `supports_dry_run: true`. `agent` is injected
|
|
1530
1551
|
# whenever the method's signature declares the keyword (so the
|
|
1531
|
-
# author can read
|
|
1552
|
+
# author can read `agent.acl_scope_kwargs` / `agent.acl_scope`
|
|
1532
1553
|
# and apply the agent's identity to internal queries the method
|
|
1533
|
-
# runs). Neither key counts against
|
|
1554
|
+
# runs). Neither key counts against `permitted_keys:`.
|
|
1534
1555
|
AGENT_METHOD_RESERVED_ARG_KEYS = %i[dry_run agent].freeze
|
|
1535
1556
|
|
|
1536
1557
|
# Refuse access to a class marked `agent_hidden`, AND validate that the
|
|
@@ -1732,6 +1753,79 @@ module Parse
|
|
|
1732
1753
|
end
|
|
1733
1754
|
module_function :apply_tenant_scope_to_pipeline
|
|
1734
1755
|
|
|
1756
|
+
# Default-deny joins under an active tenant scope. A field-based
|
|
1757
|
+
# tenant scope only constrains the OUTER collection: the outer
|
|
1758
|
+
# `$match` prepended by {#apply_tenant_scope_to_pipeline} does not
|
|
1759
|
+
# reach into a `$lookup` / `$graphLookup` / `$unionWith`
|
|
1760
|
+
# sub-pipeline, which Mongo evaluates in the JOINED collection's
|
|
1761
|
+
# context with NO tenant predicate injected.
|
|
1762
|
+
#
|
|
1763
|
+
# An earlier version allowed a join when the target class declared
|
|
1764
|
+
# the SAME `agent_tenant_scope` field, on the theory that a
|
|
1765
|
+
# tenant-aware target was safe. It is not: nothing propagates the
|
|
1766
|
+
# tenant *value* into the sub-pipeline, so an (even empty) join into
|
|
1767
|
+
# a "compatible" class still returns rows for every tenant. Until
|
|
1768
|
+
# value propagation into join sub-pipelines lands, refuse ANY join
|
|
1769
|
+
# while a tenant scope is active — the only fail-closed contract.
|
|
1770
|
+
#
|
|
1771
|
+
# @param pipeline [Array<Hash>] the caller pipeline (pre-scope-prepend)
|
|
1772
|
+
# @param scope [Hash, nil] { field:, value: } from {#resolve_tenant_scope!}
|
|
1773
|
+
# @raise [Parse::Agent::AccessDenied] on any join under an active scope
|
|
1774
|
+
def assert_joins_tenant_safe!(pipeline, scope)
|
|
1775
|
+
return unless scope
|
|
1776
|
+
return unless pipeline.is_a?(Array)
|
|
1777
|
+
scope_field = scope[:field].to_s
|
|
1778
|
+
each_join_target(pipeline) do |target|
|
|
1779
|
+
raise Parse::Agent::AccessDenied.new(
|
|
1780
|
+
target,
|
|
1781
|
+
"Join refused under an active tenant scope ('#{scope_field}'): the " \
|
|
1782
|
+
"tenant predicate is not propagated into a $lookup/$graphLookup/" \
|
|
1783
|
+
"$unionWith sub-pipeline, so the join could surface rows from other " \
|
|
1784
|
+
"tenants. Run the join without an agent tenant scope, or constrain " \
|
|
1785
|
+
"the joined class ('#{target}') explicitly.",
|
|
1786
|
+
kind: :tenant_join_denied,
|
|
1787
|
+
)
|
|
1788
|
+
end
|
|
1789
|
+
end
|
|
1790
|
+
module_function :assert_joins_tenant_safe!
|
|
1791
|
+
|
|
1792
|
+
# Yield each join-target collection name found in `$lookup` /
|
|
1793
|
+
# `$graphLookup` / `$unionWith` stages, recursing into `$facet`
|
|
1794
|
+
# branches and `$lookup`/`$unionWith` sub-pipelines. Handles BOTH
|
|
1795
|
+
# the Hash form (`{ from:/coll: ..., pipeline: [...] }`) and the
|
|
1796
|
+
# `$unionWith` String shorthand (`{ "$unionWith" => "Collection" }`)
|
|
1797
|
+
# — the latter previously slipped past every join gate because the
|
|
1798
|
+
# walkers only inspected Hash values.
|
|
1799
|
+
#
|
|
1800
|
+
# @param pipeline [Array<Hash>]
|
|
1801
|
+
# @yieldparam target [String] a join-target collection name
|
|
1802
|
+
def each_join_target(pipeline, &block)
|
|
1803
|
+
return unless pipeline.is_a?(Array)
|
|
1804
|
+
pipeline.each do |stage|
|
|
1805
|
+
next unless stage.is_a?(Hash)
|
|
1806
|
+
stage.each do |op, value|
|
|
1807
|
+
case op.to_s
|
|
1808
|
+
when "$lookup", "$graphLookup", "$unionWith"
|
|
1809
|
+
if value.is_a?(Hash)
|
|
1810
|
+
target = value["from"] || value[:from] || value["coll"] || value[:coll]
|
|
1811
|
+
yield target.to_s if target
|
|
1812
|
+
sub = value["pipeline"] || value[:pipeline]
|
|
1813
|
+
each_join_target(sub, &block) if sub.is_a?(Array)
|
|
1814
|
+
elsif value.is_a?(String)
|
|
1815
|
+
# String shorthand — only `$unionWith` accepts a bare
|
|
1816
|
+
# collection name; harmless to accept the shape for all
|
|
1817
|
+
# three since $lookup/$graphLookup never produce it.
|
|
1818
|
+
yield value
|
|
1819
|
+
end
|
|
1820
|
+
when "$facet"
|
|
1821
|
+
next unless value.is_a?(Hash)
|
|
1822
|
+
value.each_value { |branch| each_join_target(branch, &block) if branch.is_a?(Array) }
|
|
1823
|
+
end
|
|
1824
|
+
end
|
|
1825
|
+
end
|
|
1826
|
+
end
|
|
1827
|
+
module_function :each_join_target
|
|
1828
|
+
|
|
1735
1829
|
# === Per-agent filter and canonical filter helpers ===
|
|
1736
1830
|
#
|
|
1737
1831
|
# Historically a SINGLE helper merged BOTH the class-level
|
|
@@ -2181,8 +2275,16 @@ module Parse
|
|
|
2181
2275
|
stage.each do |op, value|
|
|
2182
2276
|
case op.to_s
|
|
2183
2277
|
when "$lookup", "$graphLookup", "$unionWith"
|
|
2184
|
-
|
|
2185
|
-
|
|
2278
|
+
# `$unionWith` also accepts the String shorthand
|
|
2279
|
+
# `{ "$unionWith" => "Collection" }`. Extract that target too,
|
|
2280
|
+
# otherwise the underscore denylist, hidden?, class-filter
|
|
2281
|
+
# allowlist, and CLP-find gates below silently skip it.
|
|
2282
|
+
target =
|
|
2283
|
+
if value.is_a?(Hash)
|
|
2284
|
+
value["from"] || value[:from] || value["coll"] || value[:coll]
|
|
2285
|
+
elsif value.is_a?(String)
|
|
2286
|
+
value
|
|
2287
|
+
end
|
|
2186
2288
|
target_str = nil
|
|
2187
2289
|
if target
|
|
2188
2290
|
target_str = target.to_s
|
|
@@ -2323,8 +2425,8 @@ module Parse
|
|
|
2323
2425
|
when "$match"
|
|
2324
2426
|
# $match expressions can reference any field. Without the
|
|
2325
2427
|
# field-name check, an attacker can run a boolean oracle on
|
|
2326
|
-
#
|
|
2327
|
-
# non-allowlisted column by bisecting via
|
|
2428
|
+
# `_hashed_password` or any other `agent_hidden` /
|
|
2429
|
+
# non-allowlisted column by bisecting via `$regex` and
|
|
2328
2430
|
# reading the row count delta. Apply the same field-name
|
|
2329
2431
|
# restriction as projection stages.
|
|
2330
2432
|
next unless permitted_fields && value.is_a?(Hash)
|
|
@@ -2377,9 +2479,9 @@ module Parse
|
|
|
2377
2479
|
# @api private
|
|
2378
2480
|
# Walk a $match hash refusing field keys outside the allowlist.
|
|
2379
2481
|
# Logical operators ($and/$or/$nor/$not) recurse. $expr expressions
|
|
2380
|
-
# use the field-reference walker (which understands
|
|
2381
|
-
# strings) and are also blocked at the
|
|
2382
|
-
# for forensic operators inside
|
|
2482
|
+
# use the field-reference walker (which understands `$fieldName`
|
|
2483
|
+
# strings) and are also blocked at the `PipelineSecurity` layer
|
|
2484
|
+
# for forensic operators inside `$expr`.
|
|
2383
2485
|
def check_match_keys_for_restricted_fields!(node, permitted_fields)
|
|
2384
2486
|
return unless node.is_a?(Hash)
|
|
2385
2487
|
node.each do |k, v|
|
|
@@ -3029,7 +3131,7 @@ module Parse
|
|
|
3029
3131
|
# @param include [Array<String>] pointer fields to include
|
|
3030
3132
|
# @return [Hash] query results, or a refusal hash if COLLSCAN detected
|
|
3031
3133
|
# @raise [ConstraintTranslator::ConstraintSecurityError] if blocked operators are used
|
|
3032
|
-
# Valid formats for the
|
|
3134
|
+
# Valid formats for the `format:` kwarg on query_class. "json" is
|
|
3033
3135
|
# the default and returns the structured row envelope; the others
|
|
3034
3136
|
# delegate to the export_data formatters so the conversational
|
|
3035
3137
|
# query path can produce a CSV/Markdown/text-table dump without
|
|
@@ -3415,8 +3517,8 @@ module Parse
|
|
|
3415
3517
|
end
|
|
3416
3518
|
|
|
3417
3519
|
# Validate each id format using the shared OBJECT_ID_RE so
|
|
3418
|
-
#
|
|
3419
|
-
#
|
|
3520
|
+
# `get_objects` accepts the same set of identifiers as
|
|
3521
|
+
# `get_object` — apps with custom-id schemes that use hyphens or
|
|
3420
3522
|
# underscores should not see one entry point accept the id and
|
|
3421
3523
|
# another reject it.
|
|
3422
3524
|
unique_ids.each do |id|
|
|
@@ -3639,6 +3741,10 @@ module Parse
|
|
|
3639
3741
|
# Done after pipeline validation so the injected stage doesn't
|
|
3640
3742
|
# interfere with the validator's denylist walk.
|
|
3641
3743
|
scope = resolve_tenant_scope!(agent, class_name)
|
|
3744
|
+
# Default-deny joins into tenant-incompatible classes BEFORE the
|
|
3745
|
+
# outer $match is prepended (the outer scope can't reach a join
|
|
3746
|
+
# sub-pipeline).
|
|
3747
|
+
assert_joins_tenant_safe!(pipeline, scope)
|
|
3642
3748
|
scoped_pipeline = apply_tenant_scope_to_pipeline(pipeline, scope)
|
|
3643
3749
|
|
|
3644
3750
|
# Per-agent filter (declared via Parse::Agent.new(filters: ...)) is
|
|
@@ -4319,6 +4425,7 @@ module Parse
|
|
|
4319
4425
|
def run_aggregation_for_group_tool!(agent, class_name:, pipeline:, tool:,
|
|
4320
4426
|
apply_canonical_filter: true)
|
|
4321
4427
|
scope = resolve_tenant_scope!(agent, class_name)
|
|
4428
|
+
assert_joins_tenant_safe!(pipeline, scope)
|
|
4322
4429
|
scoped = apply_tenant_scope_to_pipeline(pipeline, scope)
|
|
4323
4430
|
|
|
4324
4431
|
# TRACK-AGENT-7 split: per-agent filter is UNCONDITIONAL; canonical
|
|
@@ -4388,7 +4495,7 @@ module Parse
|
|
|
4388
4495
|
|
|
4389
4496
|
# @api private
|
|
4390
4497
|
# H2: After extract_pointer_class! resolves [cls, pairs], check whether
|
|
4391
|
-
#
|
|
4498
|
+
# `cls` is an agent_hidden class. If it is, replace every key (objectId)
|
|
4392
4499
|
# with nil so the hidden class's row identifiers are not surfaced, and
|
|
4393
4500
|
# return nil as the pointer_class so the class name is also suppressed
|
|
4394
4501
|
# from the envelope.
|
|
@@ -4682,6 +4789,7 @@ module Parse
|
|
|
4682
4789
|
def export_via_aggregate(agent, class_name:, pipeline:, scope: nil)
|
|
4683
4790
|
PipelineValidator.validate!(pipeline)
|
|
4684
4791
|
enforce_pipeline_access_policy!(class_name, pipeline, agent: agent)
|
|
4792
|
+
assert_joins_tenant_safe!(pipeline, scope)
|
|
4685
4793
|
# Prepend tenant scope $match before per-agent + canonical filter and auto-limit.
|
|
4686
4794
|
scoped_pipeline = apply_tenant_scope_to_pipeline(pipeline, scope)
|
|
4687
4795
|
# TRACK-AGENT-7 split: per-agent filter is UNCONDITIONAL.
|
|
@@ -5056,16 +5164,39 @@ module Parse
|
|
|
5056
5164
|
)
|
|
5057
5165
|
end
|
|
5058
5166
|
end
|
|
5167
|
+
# SEC-01: an instance write/admin method under an acl_user:/acl_role:
|
|
5168
|
+
# scope cannot be enforced. The receiver is fetched with READ scope
|
|
5169
|
+
# (below), but the method body's save/destroy run on the object's
|
|
5170
|
+
# default (master) client, and there is NO session token to bind as
|
|
5171
|
+
# ambient — so an acl_user agent with mere read access to a row could
|
|
5172
|
+
# mutate it with master authority (read -> write escalation). Session-
|
|
5173
|
+
# scoped agents bind the token during invocation; master agents are
|
|
5174
|
+
# admin-by-design. Only the tokenless acl_user/acl_role write case is
|
|
5175
|
+
# unenforceable, so refuse it.
|
|
5176
|
+
if method_info[:type] == :instance &&
|
|
5177
|
+
(required_perm == :write || required_perm == :admin) &&
|
|
5178
|
+
agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
5179
|
+
raise Parse::Agent::AccessDenied.new(
|
|
5180
|
+
class_name,
|
|
5181
|
+
"Instance #{required_perm} method '#{class_name}.#{method_name}' cannot be " \
|
|
5182
|
+
"invoked under an acl_user:/acl_role: scope: there is no session token to " \
|
|
5183
|
+
"bind, so the method's writes could not be enforced against the caller's " \
|
|
5184
|
+
"row ACL. Use a session-token-scoped (or master) agent for instance " \
|
|
5185
|
+
"write/admin methods.",
|
|
5186
|
+
kind: :unenforceable_scope,
|
|
5187
|
+
)
|
|
5188
|
+
end
|
|
5189
|
+
|
|
5059
5190
|
args = arguments || {}
|
|
5060
5191
|
args = args.transform_keys(&:to_sym) if args.is_a?(Hash)
|
|
5061
5192
|
|
|
5062
5193
|
# Apply mass-assignment guards. Always-denied keys come first:
|
|
5063
|
-
#
|
|
5064
|
-
#
|
|
5065
|
-
#
|
|
5066
|
-
#
|
|
5067
|
-
# through
|
|
5068
|
-
# the method declared
|
|
5194
|
+
# `_hashed_password`, `authData`, `_session_token`, `sessionToken`,
|
|
5195
|
+
# `ACL`, `objectId`, `id`, `username`, `_rperm`, `_wperm`,
|
|
5196
|
+
# `_perishable_token`, `_email_verify_token`, `createdAt`,
|
|
5197
|
+
# `updatedAt`, `className`, `__type`. These can never flow
|
|
5198
|
+
# through `call_method` regardless of `permitted_keys`. Then if
|
|
5199
|
+
# the method declared `permitted_keys`, the remaining args are
|
|
5069
5200
|
# intersected with that allowlist.
|
|
5070
5201
|
if args.is_a?(Hash) && !args.empty?
|
|
5071
5202
|
violated = args.each_key.select { |k| CALL_METHOD_DENIED_KEYS.include?(k) }
|
|
@@ -5091,8 +5222,8 @@ module Parse
|
|
|
5091
5222
|
|
|
5092
5223
|
# Dry-run handling. Two paths:
|
|
5093
5224
|
#
|
|
5094
|
-
# 1. The method declared
|
|
5095
|
-
#
|
|
5225
|
+
# 1. The method declared `supports_dry_run: true` — keep the
|
|
5226
|
+
# `dry_run` flag in `args` so the method body can branch on
|
|
5096
5227
|
# it and produce its own preview (e.g., a structural diff,
|
|
5097
5228
|
# pre-flight counters). The method controls the contract.
|
|
5098
5229
|
#
|
|
@@ -5104,9 +5235,9 @@ module Parse
|
|
|
5104
5235
|
# layer can ALWAYS safely report what the call WOULD do
|
|
5105
5236
|
# (permission tier resolved, args validated, object resolved
|
|
5106
5237
|
# when needed) without invoking the method itself. We now
|
|
5107
|
-
# return a structural preview envelope and the
|
|
5238
|
+
# return a structural preview envelope and the `dry_run` arg
|
|
5108
5239
|
# is stripped before any execution path. The preview is
|
|
5109
|
-
# explicitly flagged
|
|
5240
|
+
# explicitly flagged `supports_real_dry_run: false` so a
|
|
5110
5241
|
# consumer knows the method body wasn't consulted and a real
|
|
5111
5242
|
# dry-run (with method-author logic) isn't available.
|
|
5112
5243
|
dry_run_requested = args.key?(:dry_run) && args[:dry_run]
|
|
@@ -5117,7 +5248,10 @@ module Parse
|
|
|
5117
5248
|
# since the same lookup will fail at execution time.
|
|
5118
5249
|
if method_info[:type] == :instance
|
|
5119
5250
|
raise "object_id required for instance method '#{method_name}'" unless object_id
|
|
5120
|
-
|
|
5251
|
+
# SEC-01: resolve existence through the AGENT'S scope, not the
|
|
5252
|
+
# master default client — otherwise the dry-run is an existence
|
|
5253
|
+
# oracle for rows outside the caller's ACL/tenant scope.
|
|
5254
|
+
obj_exists = !fetch_call_method_receiver(agent, klass, class_name, object_id).nil?
|
|
5121
5255
|
unless obj_exists
|
|
5122
5256
|
raise "Object not found: #{class_name}##{object_id}"
|
|
5123
5257
|
end
|
|
@@ -5142,9 +5276,9 @@ module Parse
|
|
|
5142
5276
|
end
|
|
5143
5277
|
|
|
5144
5278
|
# If the method didn't declare dry-run support and the caller
|
|
5145
|
-
# passed a falsy
|
|
5279
|
+
# passed a falsy `dry_run` (or sent the key without it being
|
|
5146
5280
|
# truthy), strip it before forwarding — the method body has no
|
|
5147
|
-
# idea about
|
|
5281
|
+
# idea about `dry_run` and would raise ArgumentError on the
|
|
5148
5282
|
# unexpected kwarg.
|
|
5149
5283
|
if args.key?(:dry_run) && !method_info[:supports_dry_run]
|
|
5150
5284
|
args = args.reject { |k, _| k == :dry_run }
|
|
@@ -5152,14 +5286,34 @@ module Parse
|
|
|
5152
5286
|
|
|
5153
5287
|
# Execute with timeout - user methods could be slow
|
|
5154
5288
|
with_timeout(:call_method) do
|
|
5155
|
-
|
|
5289
|
+
invoke = lambda do
|
|
5290
|
+
if method_info[:type] == :instance
|
|
5156
5291
|
raise "object_id required for instance method '#{method_name}'" unless object_id
|
|
5157
|
-
|
|
5292
|
+
# SEC-01: fetch the receiver through the agent's scope (ACL/CLP/
|
|
5293
|
+
# tenant enforced), NOT the master default client. Fails closed —
|
|
5294
|
+
# a row the scope can't read surfaces as "not found".
|
|
5295
|
+
obj = fetch_call_method_receiver(agent, klass, class_name, object_id)
|
|
5158
5296
|
raise "Object not found: #{class_name}##{object_id}" unless obj
|
|
5159
5297
|
call_with_args(obj, method_sym, args, agent: agent)
|
|
5160
5298
|
else
|
|
5161
5299
|
call_with_args(klass, method_sym, args, agent: agent)
|
|
5162
5300
|
end
|
|
5301
|
+
end
|
|
5302
|
+
|
|
5303
|
+
# SEC-01: bind the agent's session token as the ambient session for
|
|
5304
|
+
# the duration of the method body, so any save/destroy the method
|
|
5305
|
+
# performs on the receiver (or an internally-loaded object) inherits
|
|
5306
|
+
# the caller's principal and is enforced against ACL/CLP server-side,
|
|
5307
|
+
# instead of silently running on the object's master default client.
|
|
5308
|
+
# Master agents (no token) run unbound by design; acl_user/acl_role
|
|
5309
|
+
# write/admin instance methods were already refused above.
|
|
5310
|
+
token = agent.respond_to?(:session_token) ? agent.session_token : nil
|
|
5311
|
+
result =
|
|
5312
|
+
if token.is_a?(String) && !token.strip.empty?
|
|
5313
|
+
Parse.with_session(token) { invoke.call }
|
|
5314
|
+
else
|
|
5315
|
+
invoke.call
|
|
5316
|
+
end
|
|
5163
5317
|
|
|
5164
5318
|
{
|
|
5165
5319
|
class_name: class_name,
|
|
@@ -5545,20 +5699,20 @@ module Parse
|
|
|
5545
5699
|
# atlas_autocomplete, atlas_faceted_search — wrap
|
|
5546
5700
|
# {Parse::AtlasSearch}. Common gating:
|
|
5547
5701
|
#
|
|
5548
|
-
# *
|
|
5549
|
-
#
|
|
5702
|
+
# * `assert_class_accessible!` enforces the global
|
|
5703
|
+
# `agent_hidden` + per-agent `classes:` allowlist before any
|
|
5550
5704
|
# search is issued.
|
|
5551
|
-
# *
|
|
5552
|
-
#
|
|
5705
|
+
# * `atlas_auth_options!` refuses unless the agent carries a
|
|
5706
|
+
# `session_token` OR was constructed with `master_atlas: true`.
|
|
5553
5707
|
# Atlas Search bypasses Parse Server entirely, so the agent
|
|
5554
5708
|
# must declare its ACL posture; reusing the implicit
|
|
5555
5709
|
# master-key signal from a session-less agent would
|
|
5556
5710
|
# silently grant Atlas-bypass authority.
|
|
5557
|
-
# *
|
|
5558
|
-
#
|
|
5711
|
+
# * `agent_fields` allowlist is intersected with the caller's
|
|
5712
|
+
# `fields:` / `highlight_field:` / facet paths at the
|
|
5559
5713
|
# boundary, and applied again to the returned documents and
|
|
5560
5714
|
# highlight payloads so a field indexed for search but
|
|
5561
|
-
# redacted by
|
|
5715
|
+
# redacted by `agent_fields` never reaches the wire.
|
|
5562
5716
|
|
|
5563
5717
|
# The Tools module declares `private` at the top of its helper
|
|
5564
5718
|
# section (above), so module-level methods defined past that
|
|
@@ -5819,6 +5973,43 @@ module Parse
|
|
|
5819
5973
|
end
|
|
5820
5974
|
module_function :execute_find_via_direct
|
|
5821
5975
|
|
|
5976
|
+
# @api private
|
|
5977
|
+
# SEC-01: fetch a `call_method` instance receiver through the AGENT'S
|
|
5978
|
+
# scope so ACL / CLP / tenant enforcement applies, instead of the
|
|
5979
|
+
# master-backed default client a bare `klass.find(object_id)` uses.
|
|
5980
|
+
# Returns a persisted Parse::Object instance, or nil when the row does
|
|
5981
|
+
# not exist or the agent's scope may not read it (fail closed — the
|
|
5982
|
+
# caller maps nil to "Object not found").
|
|
5983
|
+
#
|
|
5984
|
+
# * session-token scope -> REST fetch_object carrying the token;
|
|
5985
|
+
# Parse Server enforces ACL/CLP server-side.
|
|
5986
|
+
# * acl_user:/acl_role: scope -> mongo-direct find (there is no REST
|
|
5987
|
+
# "act as user/role" affordance); the ACL simulation gates the row.
|
|
5988
|
+
# * master posture -> unscoped fetch (admin by design).
|
|
5989
|
+
#
|
|
5990
|
+
# A cross-tenant id is refused post-fetch (AccessDenied, not "not
|
|
5991
|
+
# found") via {#assert_record_in_tenant_scope!}, matching get_object.
|
|
5992
|
+
def fetch_call_method_receiver(agent, klass, class_name, object_id)
|
|
5993
|
+
scope = resolve_tenant_scope!(agent, class_name)
|
|
5994
|
+
result =
|
|
5995
|
+
if agent.respond_to?(:acl_scope_requires_direct?) && agent.acl_scope_requires_direct?
|
|
5996
|
+
where_id = ConstraintTranslator.translate({ "objectId" => object_id }, agent)
|
|
5997
|
+
rows = execute_find_via_direct(agent, class_name, where: where_id, limit: 1)
|
|
5998
|
+
rows && rows.first
|
|
5999
|
+
else
|
|
6000
|
+
response = agent.client.fetch_object(class_name, object_id, **agent.request_opts)
|
|
6001
|
+
unless response.success?
|
|
6002
|
+
return nil if response.object_not_found?
|
|
6003
|
+
raise Parse::Error, "Fetch failed: #{response.error}"
|
|
6004
|
+
end
|
|
6005
|
+
response.result
|
|
6006
|
+
end
|
|
6007
|
+
return nil if result.nil? || (result.respond_to?(:empty?) && result.empty?)
|
|
6008
|
+
assert_record_in_tenant_scope!(result, scope, class_name)
|
|
6009
|
+
(klass || Parse::Object).build(result, class_name)
|
|
6010
|
+
end
|
|
6011
|
+
module_function :fetch_call_method_receiver
|
|
6012
|
+
|
|
5822
6013
|
# @api private
|
|
5823
6014
|
# Count variant of {#execute_find_via_direct}. Routes through
|
|
5824
6015
|
# Parse::MongoDB.aggregate with a terminal $count stage. Returns
|
|
@@ -5853,8 +6044,8 @@ module Parse
|
|
|
5853
6044
|
end
|
|
5854
6045
|
|
|
5855
6046
|
# @api private
|
|
5856
|
-
# Coerce
|
|
5857
|
-
# class's
|
|
6047
|
+
# Coerce `fields` to an array of strings and intersect with the
|
|
6048
|
+
# class's `agent_fields` allowlist. Raises {AccessDenied} when
|
|
5858
6049
|
# the caller named a field outside the allowlist — refuse, not
|
|
5859
6050
|
# silently strip, so the LLM gets a clear error rather than an
|
|
5860
6051
|
# empty result it might retry forever.
|
|
@@ -5928,9 +6119,9 @@ module Parse
|
|
|
5928
6119
|
end
|
|
5929
6120
|
|
|
5930
6121
|
# @api private
|
|
5931
|
-
# Validate that every facet's
|
|
5932
|
-
#
|
|
5933
|
-
# ensuring each value is a Hash with a string
|
|
6122
|
+
# Validate that every facet's `path:` entry is in the class's
|
|
6123
|
+
# `agent_fields` allowlist. Mutates `facets` in place by
|
|
6124
|
+
# ensuring each value is a Hash with a string `path` (the form
|
|
5934
6125
|
# Parse::AtlasSearch expects).
|
|
5935
6126
|
def normalize_atlas_facet_paths!(class_name, facets)
|
|
5936
6127
|
allowlist = Parse::Agent::MetadataRegistry.field_allowlist(class_name)
|
|
@@ -6055,7 +6246,7 @@ module Parse
|
|
|
6055
6246
|
|
|
6056
6247
|
# @api private
|
|
6057
6248
|
# Serialize a Parse::Object (or raw Hash) into the wire-format
|
|
6058
|
-
# hash used by other tool envelopes. Falls through to
|
|
6249
|
+
# hash used by other tool envelopes. Falls through to `as_json`
|
|
6059
6250
|
# for Parse objects and returns the input hash for raw-mode
|
|
6060
6251
|
# results.
|
|
6061
6252
|
def serialize_atlas_object(obj)
|
|
@@ -6070,8 +6261,8 @@ module Parse
|
|
|
6070
6261
|
end
|
|
6071
6262
|
|
|
6072
6263
|
# @api private
|
|
6073
|
-
# Drop highlight entries whose
|
|
6074
|
-
#
|
|
6264
|
+
# Drop highlight entries whose `path` is not in the
|
|
6265
|
+
# `agent_fields` allowlist. Highlight snippets carry verbatim
|
|
6075
6266
|
# field content; without this filter, a field redacted from
|
|
6076
6267
|
# the row would still leak through its highlight passage.
|
|
6077
6268
|
def filter_atlas_highlights(highlights, permitted_fields)
|