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.rb
CHANGED
|
@@ -23,6 +23,8 @@ require_relative "agent/rate_limiter"
|
|
|
23
23
|
require_relative "agent/cancellation_token"
|
|
24
24
|
require_relative "agent/log_levels"
|
|
25
25
|
require_relative "agent/approval_gate"
|
|
26
|
+
require_relative "agent/field_policy"
|
|
27
|
+
require_relative "agent/field_names"
|
|
26
28
|
require_relative "agent/prompt_hardening"
|
|
27
29
|
require_relative "agent/describe"
|
|
28
30
|
|
|
@@ -592,8 +594,10 @@ module Parse
|
|
|
592
594
|
# Use provided port, or configured port, or default
|
|
593
595
|
port ||= Parse.mcp_server_port || 3001
|
|
594
596
|
|
|
595
|
-
|
|
597
|
+
# Load the server before flagging MCP as enabled, so a failed load
|
|
598
|
+
# does not leave mcp_enabled? reporting true.
|
|
596
599
|
require_relative "agent/mcp_server"
|
|
600
|
+
@mcp_enabled = true
|
|
597
601
|
MCPServer.default_port = port
|
|
598
602
|
|
|
599
603
|
# Pass remote API config if available
|
|
@@ -1559,6 +1563,7 @@ module Parse
|
|
|
1559
1563
|
max_log_size: DEFAULT_MAX_LOG_SIZE,
|
|
1560
1564
|
system_prompt: nil, system_prompt_suffix: nil, pricing: nil,
|
|
1561
1565
|
tools: nil, methods: nil, classes: nil, filters: nil,
|
|
1566
|
+
fields: nil, field_names: nil,
|
|
1562
1567
|
parent: nil, recursion_depth: nil,
|
|
1563
1568
|
strict_tool_filter: nil, strict_class_filter: nil,
|
|
1564
1569
|
master_atlas: nil,
|
|
@@ -2033,6 +2038,21 @@ module Parse
|
|
|
2033
2038
|
@method_filter_only, @method_filter_except = normalize_method_filter(methods)
|
|
2034
2039
|
@class_filter_only, @class_filter_except = normalize_class_filter(classes)
|
|
2035
2040
|
@filters = normalize_query_filters(filters)
|
|
2041
|
+
# Per-agent field narrowing (see Parse::Agent::FieldPolicy). Stored as
|
|
2042
|
+
# layers so a sub-agent's policy INTERSECTS its parent's: every layer
|
|
2043
|
+
# that names a class must permit a field for the agent to see it.
|
|
2044
|
+
own_field_policy = normalize_field_policy(fields)
|
|
2045
|
+
@field_policy_layers = (parent ? parent.field_policy_layers : []) +
|
|
2046
|
+
(own_field_policy ? [own_field_policy] : [])
|
|
2047
|
+
@field_policy_layers.freeze
|
|
2048
|
+
# Data-field naming mode (see Parse::Agent::FieldNames). A sub-agent
|
|
2049
|
+
# inherits its parent's mode unless it sets one; naming never affects
|
|
2050
|
+
# the access restrictions inherited above.
|
|
2051
|
+
@field_names_mode = if field_names.nil?
|
|
2052
|
+
parent ? parent.field_names_mode : :default
|
|
2053
|
+
else
|
|
2054
|
+
Parse::AggregationResult.normalize_field_names!(field_names)
|
|
2055
|
+
end
|
|
2036
2056
|
|
|
2037
2057
|
# Sub-agent class-filter inheritance. Unlike `tools:` (which overrides
|
|
2038
2058
|
# outright), `classes:` clamps to the parent's effective set so a
|
|
@@ -3428,6 +3448,49 @@ module Parse
|
|
|
3428
3448
|
# lookup re-expands the variants and accepts both forms symmetrically.
|
|
3429
3449
|
#
|
|
3430
3450
|
# @return [Hash, nil] frozen Hash or nil when no filters declared
|
|
3451
|
+
# Normalize the constructor's `fields:` kwarg: a Hash mapping class
|
|
3452
|
+
# identifiers (Class, String, Symbol, or :default) to an Array of field
|
|
3453
|
+
# names. Keys canonicalize like `filters:` so `Parse::User` matches both
|
|
3454
|
+
# "_User" and "User". Field names may be Ruby property names or wire
|
|
3455
|
+
# names; they are translated to wire form at lookup time.
|
|
3456
|
+
def normalize_field_policy(fields)
|
|
3457
|
+
return nil if fields.nil?
|
|
3458
|
+
unless fields.is_a?(Hash)
|
|
3459
|
+
raise ArgumentError,
|
|
3460
|
+
"fields: must be a Hash mapping class identifiers (or :default) " \
|
|
3461
|
+
"to Arrays of field names, got #{fields.class}"
|
|
3462
|
+
end
|
|
3463
|
+
result = {}
|
|
3464
|
+
fields.each do |key, names|
|
|
3465
|
+
unless names.is_a?(Array) && names.all? { |n| n.is_a?(String) || n.is_a?(Symbol) }
|
|
3466
|
+
raise ArgumentError,
|
|
3467
|
+
"fields[#{key.inspect}]: value must be an Array of field names, got #{names.inspect}"
|
|
3468
|
+
end
|
|
3469
|
+
field_policy_keys(key).each { |canon| result[canon] = names.map(&:to_s).freeze }
|
|
3470
|
+
end
|
|
3471
|
+
result.freeze
|
|
3472
|
+
end
|
|
3473
|
+
|
|
3474
|
+
# Canonical lookup names for a `fields:` key. Tools look policies up by
|
|
3475
|
+
# the class's Parse name (`_User`, or a custom `parse_class`), so a
|
|
3476
|
+
# String or Symbol key is resolved through Parse::Model.find_class to
|
|
3477
|
+
# that name as well as kept verbatim; otherwise `fields: { "User" => ... }`
|
|
3478
|
+
# would silently fail to narrow `_User`.
|
|
3479
|
+
def field_policy_keys(key)
|
|
3480
|
+
keys = canonical_filter_key(key)
|
|
3481
|
+
if key.is_a?(String) || key.is_a?(Symbol)
|
|
3482
|
+
klass = (Parse::Model.find_class(key.to_s) rescue nil)
|
|
3483
|
+
if klass.respond_to?(:parse_class)
|
|
3484
|
+
keys |= [klass.parse_class.to_s]
|
|
3485
|
+
if defined?(Parse::Agent::MetadataRegistry) &&
|
|
3486
|
+
Parse::Agent::MetadataRegistry.respond_to?(:hidden_name_variants_for)
|
|
3487
|
+
keys |= Parse::Agent::MetadataRegistry.hidden_name_variants_for(klass)
|
|
3488
|
+
end
|
|
3489
|
+
end
|
|
3490
|
+
end
|
|
3491
|
+
keys
|
|
3492
|
+
end
|
|
3493
|
+
|
|
3431
3494
|
def normalize_query_filters(filters)
|
|
3432
3495
|
return nil if filters.nil?
|
|
3433
3496
|
unless filters.is_a?(Hash)
|
|
@@ -3567,6 +3630,35 @@ module Parse
|
|
|
3567
3630
|
#
|
|
3568
3631
|
# @param class_name [String, Symbol, Class]
|
|
3569
3632
|
# @return [Boolean]
|
|
3633
|
+
# @return [Array<Hash>] the per-agent `fields:` policies in effect, own
|
|
3634
|
+
# policy last; a sub-agent carries its parent's layers first. Each
|
|
3635
|
+
# layer maps a canonical class name (or :default) to raw field names.
|
|
3636
|
+
attr_reader :field_policy_layers
|
|
3637
|
+
|
|
3638
|
+
# @return [Symbol] `:default` or `:server`; see {Parse::Agent::FieldNames}.
|
|
3639
|
+
attr_reader :field_names_mode
|
|
3640
|
+
|
|
3641
|
+
# Wire-format field names this agent narrows `class_name` to, or nil when
|
|
3642
|
+
# no `fields:` layer names the class (or a :default). Every layer that
|
|
3643
|
+
# applies must permit a field, so a sub-agent can never widen its
|
|
3644
|
+
# parent. The result is intersected with the class's `agent_fields`
|
|
3645
|
+
# ceiling by {Parse::Agent::MetadataRegistry.field_allowlist}.
|
|
3646
|
+
#
|
|
3647
|
+
# @param class_name [String, Class]
|
|
3648
|
+
# @return [Array<String>, nil]
|
|
3649
|
+
def field_narrowing_for(class_name)
|
|
3650
|
+
return nil if @field_policy_layers.nil? || @field_policy_layers.empty?
|
|
3651
|
+
cn = class_name.respond_to?(:parse_class) ? class_name.parse_class.to_s : class_name.to_s
|
|
3652
|
+
result = nil
|
|
3653
|
+
@field_policy_layers.each do |layer|
|
|
3654
|
+
names = layer[cn] || layer[:default]
|
|
3655
|
+
next if names.nil?
|
|
3656
|
+
wire = Parse::Agent::MetadataRegistry.wire_field_names(cn, names)
|
|
3657
|
+
result = result ? (result & wire) : wire
|
|
3658
|
+
end
|
|
3659
|
+
result
|
|
3660
|
+
end
|
|
3661
|
+
|
|
3570
3662
|
def class_filter_permits?(class_name)
|
|
3571
3663
|
return true if @class_filter_only.nil? && @class_filter_except.nil?
|
|
3572
3664
|
candidates = class_name_variants_for(class_name)
|
data/lib/parse/api/batch.rb
CHANGED
|
@@ -26,7 +26,16 @@ module Parse
|
|
|
26
26
|
batch_operations = Parse::BatchOperation.new batch_operations
|
|
27
27
|
end
|
|
28
28
|
response = request(:post, "batch", body: batch_operations.as_json)
|
|
29
|
-
response.success? && response.batch?
|
|
29
|
+
return response.batch_responses if response.success? && response.batch?
|
|
30
|
+
return response if response.error?
|
|
31
|
+
# A successful HTTP response whose body is not an array of results
|
|
32
|
+
# cannot be matched to the submitted requests. Report it as a failure
|
|
33
|
+
# rather than as a batch where every write landed.
|
|
34
|
+
Parse::Response.error_response(
|
|
35
|
+
Parse::Response::ERROR_INTERNAL,
|
|
36
|
+
"Malformed batch response: expected an array of results",
|
|
37
|
+
http_status: response.http_status,
|
|
38
|
+
).tap { |r| r.request = response.request }
|
|
30
39
|
end
|
|
31
40
|
end
|
|
32
41
|
end
|
data/lib/parse/api/schema.rb
CHANGED
|
@@ -8,11 +8,19 @@ module Parse
|
|
|
8
8
|
# @!visibility private
|
|
9
9
|
SCHEMAS_PATH = "schemas"
|
|
10
10
|
|
|
11
|
+
# Parse Server serves `/schemas` to the master key only. Every call
|
|
12
|
+
# here therefore asks for the master key explicitly (outside client
|
|
13
|
+
# mode), which also stops an ambient `Parse.with_session` token or a
|
|
14
|
+
# client-bound token from being attached in its place. With a session token attached the request was
|
|
15
|
+
# refused with 403, and callers that cache the class permissions (the
|
|
16
|
+
# CLP scope used by mongo-direct queries) then denied a direct query
|
|
17
|
+
# that the master-keyed client was entitled to run.
|
|
18
|
+
|
|
11
19
|
# Get all the schemas for the application.
|
|
12
20
|
# @param opts [Hash] additional options for the request.
|
|
13
21
|
# @return [Parse::Response]
|
|
14
22
|
def schemas(opts = {})
|
|
15
|
-
request_opts = { cache: false }.merge(opts)
|
|
23
|
+
request_opts = { cache: false }.merge(schema_auth_opts).merge(opts)
|
|
16
24
|
request :get, SCHEMAS_PATH, opts: request_opts
|
|
17
25
|
end
|
|
18
26
|
|
|
@@ -21,7 +29,7 @@ module Parse
|
|
|
21
29
|
# @return [Parse::Response]
|
|
22
30
|
def schema(className)
|
|
23
31
|
safe = Parse::API::PathSegment.identifier!(className, kind: "class name")
|
|
24
|
-
opts = { cache: false }
|
|
32
|
+
opts = { cache: false }.merge(schema_auth_opts)
|
|
25
33
|
request :get, "#{SCHEMAS_PATH}/#{safe}", opts: opts
|
|
26
34
|
end
|
|
27
35
|
|
|
@@ -32,7 +40,7 @@ module Parse
|
|
|
32
40
|
# @return [Parse::Response]
|
|
33
41
|
def create_schema(className, schema)
|
|
34
42
|
safe = Parse::API::PathSegment.identifier!(className, kind: "class name")
|
|
35
|
-
request :post, "#{SCHEMAS_PATH}/#{safe}", body: schema
|
|
43
|
+
request :post, "#{SCHEMAS_PATH}/#{safe}", body: schema, opts: schema_auth_opts
|
|
36
44
|
end
|
|
37
45
|
|
|
38
46
|
# Update the schema for a collection.
|
|
@@ -42,7 +50,18 @@ module Parse
|
|
|
42
50
|
# @return [Parse::Response]
|
|
43
51
|
def update_schema(className, schema)
|
|
44
52
|
safe = Parse::API::PathSegment.identifier!(className, kind: "class name")
|
|
45
|
-
request :put, "#{SCHEMAS_PATH}/#{safe}", body: schema
|
|
53
|
+
request :put, "#{SCHEMAS_PATH}/#{safe}", body: schema, opts: schema_auth_opts
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# Ask for the master key explicitly, except under `Parse.client_mode`,
|
|
59
|
+
# whose contract is that the SDK never sends the master key on its own
|
|
60
|
+
# initiative. A client with no master key configured sends none either
|
|
61
|
+
# way.
|
|
62
|
+
# @!visibility private
|
|
63
|
+
def schema_auth_opts
|
|
64
|
+
{ use_master_key: !Parse.client_mode }
|
|
46
65
|
end
|
|
47
66
|
end #Schema
|
|
48
67
|
end #API
|
data/lib/parse/api/sessions.rb
CHANGED
|
@@ -13,8 +13,12 @@ module Parse
|
|
|
13
13
|
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
14
14
|
# @return [Parse::Response]
|
|
15
15
|
def fetch_session(session_token, **opts)
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
# The positional token is passed as the explicit per-call token so an
|
|
17
|
+
# ambient `Parse.with_session` token or a client-bound token cannot
|
|
18
|
+
# replace it and return another user's session.
|
|
19
|
+
session_token = session_token.session_token if session_token.respond_to?(:session_token)
|
|
20
|
+
opts = opts.merge({ use_master_key: false, cache: false, session_token: session_token.to_s })
|
|
21
|
+
headers = { Parse::Protocol::SESSION_TOKEN => session_token.to_s }
|
|
18
22
|
response = request :get, "#{SESSION_PATH_PREFIX}/me", headers: headers, opts: opts
|
|
19
23
|
response.parse_class = Parse::Model::CLASS_SESSION
|
|
20
24
|
response
|
data/lib/parse/api/users.rb
CHANGED
|
@@ -47,7 +47,14 @@ module Parse
|
|
|
47
47
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
48
48
|
# @return [Parse::Response]
|
|
49
49
|
def current_user(session_token, headers: {}, **opts)
|
|
50
|
-
|
|
50
|
+
# The token argument is the whole point of this call, so it is passed
|
|
51
|
+
# as the explicit per-call token: an ambient `Parse.with_session`
|
|
52
|
+
# token or a client-bound token must never replace it (that resolved
|
|
53
|
+
# the wrong user and poisoned the identity cache). A caller-supplied
|
|
54
|
+
# `session_token:` in opts is ignored for the same reason.
|
|
55
|
+
session_token = session_token.session_token if session_token.respond_to?(:session_token)
|
|
56
|
+
opts = opts.merge(session_token: session_token.to_s, use_master_key: false)
|
|
57
|
+
headers = headers.merge({ Parse::Protocol::SESSION_TOKEN => session_token.to_s })
|
|
51
58
|
response = request :get, "#{USER_PATH_PREFIX}/me", headers: headers, opts: opts
|
|
52
59
|
response.parse_class = Parse::Model::CLASS_USER
|
|
53
60
|
response
|
|
@@ -58,10 +65,23 @@ module Parse
|
|
|
58
65
|
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
59
66
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
60
67
|
# @return [Parse::Response]
|
|
68
|
+
#
|
|
69
|
+
# Sent WITHOUT the master key unless the caller passes
|
|
70
|
+
# `use_master_key: true`. Parse Server does not mint a session token for
|
|
71
|
+
# a master-key signup, so a master-keyed client used to get back a user
|
|
72
|
+
# with no `sessionToken` (and {Parse::User#upgrade_anonymous!} then
|
|
73
|
+
# failed). A master-key create also lets the request through `_User`
|
|
74
|
+
# create CLPs and authData checks that a real signup must pass.
|
|
61
75
|
def create_user(body, headers: {}, **opts)
|
|
62
|
-
|
|
76
|
+
opts = opts.merge(use_master_key: false) unless opts[:use_master_key] == true
|
|
77
|
+
headers = headers.merge({ Parse::Protocol::REVOCABLE_SESSION => "1" })
|
|
63
78
|
if opts[:session_token].present?
|
|
64
|
-
headers.merge
|
|
79
|
+
headers = headers.merge({ Parse::Protocol::SESSION_TOKEN => opts[:session_token] })
|
|
80
|
+
elsif opts[:use_master_key] != true
|
|
81
|
+
# No token given: send none, rather than letting an ambient
|
|
82
|
+
# `with_session` token or a client-bound token make the caller the
|
|
83
|
+
# `request.user` of someone else's signup.
|
|
84
|
+
opts = opts.merge(session_token: "")
|
|
65
85
|
end
|
|
66
86
|
response = request :post, USER_PATH_PREFIX, body: body, headers: headers, opts: opts
|
|
67
87
|
response.parse_class = Parse::Model::CLASS_USER
|
|
@@ -78,6 +98,10 @@ module Parse
|
|
|
78
98
|
id = Parse::API::PathSegment.object_id!(id)
|
|
79
99
|
response = request :put, "#{USER_PATH_PREFIX}/#{id}", body: body, headers: headers, opts: opts
|
|
80
100
|
response.parse_class = Parse::Model::CLASS_USER
|
|
101
|
+
# A password change revokes the user's other sessions server-side.
|
|
102
|
+
if response.success? && body.is_a?(Hash) && (body.key?(:password) || body.key?("password"))
|
|
103
|
+
invalidate_user_identity(id)
|
|
104
|
+
end
|
|
81
105
|
response
|
|
82
106
|
end
|
|
83
107
|
|
|
@@ -101,7 +125,9 @@ module Parse
|
|
|
101
125
|
# @return [Parse::Response]
|
|
102
126
|
def delete_user(id, headers: {}, **opts)
|
|
103
127
|
id = Parse::API::PathSegment.object_id!(id)
|
|
104
|
-
request :delete, "#{USER_PATH_PREFIX}/#{id}", headers: headers, opts: opts
|
|
128
|
+
response = request :delete, "#{USER_PATH_PREFIX}/#{id}", headers: headers, opts: opts
|
|
129
|
+
invalidate_user_identity(id) if response.success?
|
|
130
|
+
response
|
|
105
131
|
end
|
|
106
132
|
|
|
107
133
|
# Request a password reset for a registered email.
|
|
@@ -127,7 +153,7 @@ module Parse
|
|
|
127
153
|
rate_key = "pwreset:#{email}"
|
|
128
154
|
check_login_rate_limit!(rate_key)
|
|
129
155
|
body = { email: email }
|
|
130
|
-
response = request :post, REQUEST_PASSWORD_RESET, body: body, opts: opts, headers: headers
|
|
156
|
+
response = request :post, REQUEST_PASSWORD_RESET, body: body, opts: unauthenticated_opts(opts), headers: headers
|
|
131
157
|
# Always count the attempt as a "failure" for backoff purposes:
|
|
132
158
|
# the response body is intentionally indistinguishable across
|
|
133
159
|
# found/not-found emails, so we cannot reset the counter on
|
|
@@ -150,7 +176,7 @@ module Parse
|
|
|
150
176
|
rate_key = "emailverify:#{email}"
|
|
151
177
|
check_login_rate_limit!(rate_key)
|
|
152
178
|
body = { email: email }
|
|
153
|
-
response = request :post, VERIFICATION_EMAIL_REQUEST, body: body, opts: opts, headers: headers
|
|
179
|
+
response = request :post, VERIFICATION_EMAIL_REQUEST, body: body, opts: unauthenticated_opts(opts), headers: headers
|
|
154
180
|
# Indistinguishable found/not-found response, like password reset — count
|
|
155
181
|
# every attempt toward backoff so probing can't reset the counter.
|
|
156
182
|
track_login_attempt(rate_key, false)
|
|
@@ -164,11 +190,16 @@ module Parse
|
|
|
164
190
|
# @param headers [Hash] additional HTTP headers to send with the request.
|
|
165
191
|
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
166
192
|
# @return [Parse::Response]
|
|
193
|
+
#
|
|
194
|
+
# Always sent without the master key and without any ambient or bound
|
|
195
|
+
# session token (see {#unauthenticated_opts}). A master-key login makes
|
|
196
|
+
# Parse Server skip the additional MFA check and SAVE the submitted
|
|
197
|
+
# authData, so it must never be sent from a master-keyed client.
|
|
167
198
|
def login(username, password, headers: {}, **opts)
|
|
168
199
|
check_login_rate_limit!(username)
|
|
169
200
|
body = { username: username, password: password }
|
|
170
|
-
headers.merge
|
|
171
|
-
response = request :post, LOGIN_PATH, body: body, headers: headers, opts: opts
|
|
201
|
+
headers = headers.merge({ Parse::Protocol::REVOCABLE_SESSION => "1" })
|
|
202
|
+
response = request :post, LOGIN_PATH, body: body, headers: headers, opts: unauthenticated_opts(opts)
|
|
172
203
|
response.parse_class = Parse::Model::CLASS_USER
|
|
173
204
|
track_login_attempt(username, response.success?)
|
|
174
205
|
response
|
|
@@ -200,8 +231,12 @@ module Parse
|
|
|
200
231
|
},
|
|
201
232
|
},
|
|
202
233
|
}
|
|
203
|
-
|
|
204
|
-
|
|
234
|
+
# Never with the master key: with it, Parse Server skips the MFA
|
|
235
|
+
# validation and stores the submitted `authData.mfa` over the
|
|
236
|
+
# account's enrolled TOTP secret, so any code "verifies" and MFA is
|
|
237
|
+
# silently broken for the account from then on.
|
|
238
|
+
headers = headers.merge({ Parse::Protocol::REVOCABLE_SESSION => "1" })
|
|
239
|
+
response = request :post, LOGIN_PATH, body: body, headers: headers, opts: unauthenticated_opts(opts)
|
|
205
240
|
response.parse_class = Parse::Model::CLASS_USER
|
|
206
241
|
track_login_attempt(username, response.success?)
|
|
207
242
|
response
|
|
@@ -242,7 +277,7 @@ module Parse
|
|
|
242
277
|
def verify_password(username, password, headers: {}, **opts)
|
|
243
278
|
check_login_rate_limit!(username)
|
|
244
279
|
body = { username: username, password: password }
|
|
245
|
-
response = request :post, VERIFY_PASSWORD_PATH, body: body, headers: headers, opts: opts
|
|
280
|
+
response = request :post, VERIFY_PASSWORD_PATH, body: body, headers: headers, opts: unauthenticated_opts(opts)
|
|
246
281
|
response.parse_class = Parse::Model::CLASS_USER
|
|
247
282
|
track_login_attempt(username, response.success?)
|
|
248
283
|
response
|
|
@@ -254,9 +289,14 @@ module Parse
|
|
|
254
289
|
# @param opts [Hash] additional options to pass to the {Parse::Client} request.
|
|
255
290
|
# @return [Parse::Response]
|
|
256
291
|
def logout(session_token, headers: {}, **opts)
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
292
|
+
session_token = session_token.session_token if session_token.respond_to?(:session_token)
|
|
293
|
+
headers = headers.merge({ Parse::Protocol::SESSION_TOKEN => session_token })
|
|
294
|
+
opts = opts.merge({ use_master_key: false, session_token: session_token })
|
|
295
|
+
response = request :post, LOGOUT_PATH, headers: headers, opts: opts
|
|
296
|
+
# Forget the token in this client's identity plane so mongo-direct
|
|
297
|
+
# reads see the revocation now rather than when the entry expires.
|
|
298
|
+
invalidate_session_identity(session_token) if response.success?
|
|
299
|
+
response
|
|
260
300
|
end
|
|
261
301
|
|
|
262
302
|
# Signup a user given a username, password and, optionally, their email.
|
|
@@ -272,8 +312,42 @@ module Parse
|
|
|
272
312
|
create_user(body, **opts)
|
|
273
313
|
end
|
|
274
314
|
|
|
315
|
+
# Drop one session token from this client's identity plane.
|
|
316
|
+
# @!visibility private
|
|
317
|
+
# @param session_token [String]
|
|
318
|
+
def invalidate_session_identity(session_token)
|
|
319
|
+
return if session_token.nil? || session_token.to_s.empty?
|
|
320
|
+
authorization.invalidate(session_token) if respond_to?(:authorization)
|
|
321
|
+
rescue StandardError
|
|
322
|
+
nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Drop every identity entry that resolves to `user_id` from this
|
|
326
|
+
# client's identity plane, after an event that revokes the user's
|
|
327
|
+
# sessions (password change, account deletion, logout everywhere).
|
|
328
|
+
# @!visibility private
|
|
329
|
+
# @param user_id [String]
|
|
330
|
+
def invalidate_user_identity(user_id)
|
|
331
|
+
return if user_id.nil? || user_id.to_s.empty?
|
|
332
|
+
authorization.invalidate_user(user_id) if respond_to?(:authorization)
|
|
333
|
+
rescue StandardError
|
|
334
|
+
nil
|
|
335
|
+
end
|
|
336
|
+
|
|
275
337
|
private
|
|
276
338
|
|
|
339
|
+
# Request options for an endpoint that authenticates by the request
|
|
340
|
+
# body itself (login, MFA login, verifyPassword, password reset,
|
|
341
|
+
# verification email). Forces the master key off, and passes an
|
|
342
|
+
# explicitly blank session token so neither the ambient
|
|
343
|
+
# `Parse.with_session` token nor a client-bound token is attached:
|
|
344
|
+
# {Parse::Client#request} treats an explicit blank token as "send no
|
|
345
|
+
# credential". A caller cannot opt back into the master key here.
|
|
346
|
+
# @!visibility private
|
|
347
|
+
def unauthenticated_opts(opts)
|
|
348
|
+
opts.merge(use_master_key: false, session_token: "")
|
|
349
|
+
end
|
|
350
|
+
|
|
277
351
|
# @!visibility private
|
|
278
352
|
# Thread-safe tracker for login rate limiting. Keys are usernames, values are
|
|
279
353
|
# { failures: Integer, locked_until: Time }.
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "../clp_scope"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
module AtlasSearch
|
|
8
|
+
# Shared "does this path touch a protected field" logic for every
|
|
9
|
+
# Atlas Search and vector search entry point.
|
|
10
|
+
#
|
|
11
|
+
# Stripping a protected field from the RESULT rows does not stop it
|
|
12
|
+
# from deciding which documents MATCH, how they RANK, or which rows a
|
|
13
|
+
# filter keeps. A scoped caller that can name a protected field in a
|
|
14
|
+
# `$search` path, a highlight, an autocomplete field, a sort, or a
|
|
15
|
+
# `$match` filter key can test guesses against its value. These
|
|
16
|
+
# helpers find every such reference so the entry points can refuse
|
|
17
|
+
# the call with {Parse::CLPScope::Denied}.
|
|
18
|
+
#
|
|
19
|
+
# Path comparison rules:
|
|
20
|
+
# * A String or Symbol path is reduced to its first dotted segment,
|
|
21
|
+
# with a `_p_` storage prefix removed (`_p_owner` counts as `owner`,
|
|
22
|
+
# `secret.sub` counts as `secret`). The storage columns
|
|
23
|
+
# `_created_at` / `_updated_at` / `_id` map to `createdAt` /
|
|
24
|
+
# `updatedAt` / `objectId`.
|
|
25
|
+
# * An Array path touches a protected field when any element does.
|
|
26
|
+
# * A `{ "value" => ..., "multi" => ... }` path object is judged by
|
|
27
|
+
# its `value`.
|
|
28
|
+
# * A `{ "wildcard" => ... }` path object, or any other non-String
|
|
29
|
+
# path, is treated as touching every field.
|
|
30
|
+
#
|
|
31
|
+
# Every check is a no-op for a master resolution, a nil resolution,
|
|
32
|
+
# or an empty protected set.
|
|
33
|
+
module ProtectedPaths
|
|
34
|
+
# Storage-form column names that differ from their Parse field name.
|
|
35
|
+
STORAGE_ALIASES = {
|
|
36
|
+
"_created_at" => "createdAt",
|
|
37
|
+
"_updated_at" => "updatedAt",
|
|
38
|
+
"_id" => "objectId",
|
|
39
|
+
}.freeze
|
|
40
|
+
|
|
41
|
+
# Query operators whose value is a list of sub-filters.
|
|
42
|
+
LOGICAL_OPERATORS = %w[$and $or $nor].freeze
|
|
43
|
+
|
|
44
|
+
# Sentinel yielded for a reference that reaches every field
|
|
45
|
+
# (a wildcard path, a queryString query).
|
|
46
|
+
ALL_FIELDS = :__all_fields__
|
|
47
|
+
|
|
48
|
+
module_function
|
|
49
|
+
|
|
50
|
+
# @return [Boolean] true when the scope is subject to protectedFields.
|
|
51
|
+
def enforce?(resolution, protected_fields)
|
|
52
|
+
return false if resolution.nil?
|
|
53
|
+
return false if resolution.respond_to?(:master?) && resolution.master?
|
|
54
|
+
!(protected_fields.nil? || protected_fields.empty?)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# The Parse field name a single String path addresses.
|
|
58
|
+
# @return [String, nil]
|
|
59
|
+
def root_field(path)
|
|
60
|
+
head = path.to_s.split(".").first.to_s
|
|
61
|
+
return nil if head.empty?
|
|
62
|
+
head = STORAGE_ALIASES.fetch(head, head)
|
|
63
|
+
head = head.delete_prefix("_p_") if head.start_with?("_p_") && head.length > 3
|
|
64
|
+
head
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Whether `path` touches a protected field.
|
|
68
|
+
#
|
|
69
|
+
# @param path [String, Symbol, Array, Hash, Object] an Atlas path.
|
|
70
|
+
# @param protected_fields [Set<String>, Array<String>]
|
|
71
|
+
# @return [Boolean]
|
|
72
|
+
def touches?(path, protected_fields)
|
|
73
|
+
return false if protected_fields.nil? || protected_fields.empty?
|
|
74
|
+
case path
|
|
75
|
+
when String, Symbol
|
|
76
|
+
protected_fields.include?(root_field(path))
|
|
77
|
+
when Array
|
|
78
|
+
path.any? { |p| touches?(p, protected_fields) }
|
|
79
|
+
when Hash
|
|
80
|
+
value = path.key?("value") ? path["value"] : path[:value]
|
|
81
|
+
has_wildcard = path.key?("wildcard") || path.key?(:wildcard)
|
|
82
|
+
return true if has_wildcard || value.nil?
|
|
83
|
+
touches?(value, protected_fields)
|
|
84
|
+
else
|
|
85
|
+
true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Refuse when any of `paths` touches a protected field.
|
|
90
|
+
#
|
|
91
|
+
# @param paths [Object] a single path or an Array of paths.
|
|
92
|
+
# @param what [String] used in the error message ("search path",
|
|
93
|
+
# "highlight path", ...).
|
|
94
|
+
# @raise [Parse::CLPScope::Denied]
|
|
95
|
+
def assert_paths_allowed!(paths, protected_fields, resolution, collection_name: nil,
|
|
96
|
+
method_name: "Parse::AtlasSearch.search",
|
|
97
|
+
what: "path")
|
|
98
|
+
return unless enforce?(resolution, protected_fields)
|
|
99
|
+
list = paths.is_a?(Array) ? paths : [paths]
|
|
100
|
+
hit = list.find { |p| touches?(p, protected_fields) }
|
|
101
|
+
return if hit.nil? && !list.empty?
|
|
102
|
+
raise_denied!(collection_name, method_name, what, hit.nil? ? ALL_FIELDS : hit)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Walk a `$search` / `$searchMeta` body (or a whole stage) and
|
|
106
|
+
# refuse any path, sort key, or query-string reference that touches
|
|
107
|
+
# a protected field. Operators nest freely (compound
|
|
108
|
+
# must/should/filter/mustNot, embeddedDocument, facet operators,
|
|
109
|
+
# score functions), so every `path` / `defaultPath` value at any
|
|
110
|
+
# depth is checked.
|
|
111
|
+
#
|
|
112
|
+
# @raise [Parse::CLPScope::Denied]
|
|
113
|
+
def assert_search_stage_allowed!(stage, protected_fields, resolution, collection_name: nil,
|
|
114
|
+
method_name: "Parse::AtlasSearch.search_with_stage")
|
|
115
|
+
return unless enforce?(resolution, protected_fields)
|
|
116
|
+
each_search_reference(stage) do |ref|
|
|
117
|
+
next unless ref == ALL_FIELDS || touches?(ref, protected_fields)
|
|
118
|
+
raise_denied!(collection_name, method_name, "$search path", ref)
|
|
119
|
+
end
|
|
120
|
+
nil
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Yield every field reference in a `$search`-style body.
|
|
124
|
+
# @!visibility private
|
|
125
|
+
def each_search_reference(node, &block)
|
|
126
|
+
case node
|
|
127
|
+
when Array
|
|
128
|
+
node.each { |child| each_search_reference(child, &block) }
|
|
129
|
+
when Hash
|
|
130
|
+
node.each do |key, value|
|
|
131
|
+
case key.to_s
|
|
132
|
+
when "path", "defaultPath"
|
|
133
|
+
yield value
|
|
134
|
+
when "sort"
|
|
135
|
+
if value.is_a?(Hash)
|
|
136
|
+
value.each do |sort_key, dir|
|
|
137
|
+
# `{ score: { $meta: "searchScore" } }` sorts by relevance,
|
|
138
|
+
# not by a stored field.
|
|
139
|
+
next if dir.is_a?(Hash) && (dir.key?("$meta") || dir.key?(:$meta))
|
|
140
|
+
yield sort_key
|
|
141
|
+
end
|
|
142
|
+
else
|
|
143
|
+
each_search_reference(value, &block)
|
|
144
|
+
end
|
|
145
|
+
when "queryString"
|
|
146
|
+
# The Lucene query syntax can name any field (`ssn:123*`),
|
|
147
|
+
# so the query text reaches every field.
|
|
148
|
+
yield ALL_FIELDS
|
|
149
|
+
each_search_reference(value, &block)
|
|
150
|
+
when "moreLikeThis"
|
|
151
|
+
like = value.is_a?(Hash) ? (value["like"] || value[:like]) : nil
|
|
152
|
+
Array(like).each do |doc|
|
|
153
|
+
doc.is_a?(Hash) ? doc.each_key { |k| yield k } : yield(ALL_FIELDS)
|
|
154
|
+
end
|
|
155
|
+
each_search_reference(value, &block)
|
|
156
|
+
else
|
|
157
|
+
each_search_reference(value, &block)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
nil
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Refuse a `$match` filter whose predicate KEYS (top level, and
|
|
165
|
+
# inside `$and` / `$or` / `$nor` / `$not`) or `$expr` field
|
|
166
|
+
# references touch a protected field. A filter decides which rows
|
|
167
|
+
# survive, so filtering on a protected field is an oracle on its
|
|
168
|
+
# value even when the field is stripped from the output.
|
|
169
|
+
#
|
|
170
|
+
# @raise [Parse::CLPScope::Denied]
|
|
171
|
+
def assert_filter_allowed!(filter, protected_fields, resolution, collection_name: nil,
|
|
172
|
+
method_name: "Parse::AtlasSearch.search")
|
|
173
|
+
return unless enforce?(resolution, protected_fields)
|
|
174
|
+
each_filter_reference(filter) do |ref|
|
|
175
|
+
next unless touches?(ref, protected_fields)
|
|
176
|
+
raise_denied!(collection_name, method_name, "filter field", ref)
|
|
177
|
+
end
|
|
178
|
+
nil
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Yield every field name a `$match` filter predicates on.
|
|
182
|
+
# @!visibility private
|
|
183
|
+
def each_filter_reference(node, &block)
|
|
184
|
+
case node
|
|
185
|
+
when Array
|
|
186
|
+
node.each { |child| each_filter_reference(child, &block) }
|
|
187
|
+
when Hash
|
|
188
|
+
node.each do |key, value|
|
|
189
|
+
k = key.to_s
|
|
190
|
+
if LOGICAL_OPERATORS.include?(k) || k == "$not"
|
|
191
|
+
each_filter_reference(value, &block)
|
|
192
|
+
elsif k == "$expr"
|
|
193
|
+
each_expr_reference(value, &block)
|
|
194
|
+
elsif k.start_with?("$")
|
|
195
|
+
# Other top-level operators ($text, $comment, ...) do not
|
|
196
|
+
# name a stored field by key.
|
|
197
|
+
next
|
|
198
|
+
else
|
|
199
|
+
yield k
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
nil
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Yield `$field` references inside an aggregation expression.
|
|
207
|
+
# `$$VAR` references are variables, not fields.
|
|
208
|
+
# @!visibility private
|
|
209
|
+
def each_expr_reference(node, &block)
|
|
210
|
+
case node
|
|
211
|
+
when String
|
|
212
|
+
yield node[1..] if node.start_with?("$") && !node.start_with?("$$") && node.length > 1
|
|
213
|
+
when Array
|
|
214
|
+
node.each { |child| each_expr_reference(child, &block) }
|
|
215
|
+
when Hash
|
|
216
|
+
node.each_value { |child| each_expr_reference(child, &block) }
|
|
217
|
+
end
|
|
218
|
+
nil
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# @!visibility private
|
|
222
|
+
def raise_denied!(collection_name, method_name, what, ref)
|
|
223
|
+
detail = if ref == ALL_FIELDS
|
|
224
|
+
"a #{what} that reaches every field would match on protectedFields " \
|
|
225
|
+
"for the current scope; name the fields to search explicitly."
|
|
226
|
+
else
|
|
227
|
+
field = ref.is_a?(String) || ref.is_a?(Symbol) ? root_field(ref) : nil
|
|
228
|
+
label = field ? "'#{ref}' touches protected field '#{field}'" : "#{ref.inspect} reaches every field"
|
|
229
|
+
"#{what} #{label} for the current scope; matching, ranking, or " \
|
|
230
|
+
"filtering on it would reveal its value."
|
|
231
|
+
end
|
|
232
|
+
raise Parse::CLPScope::Denied.new(collection_name, :find, "#{method_name} refused: #{detail}")
|
|
233
|
+
end
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
end
|