parse-stack-next 4.5.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 +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,2300 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "date"
|
|
5
|
+
require "set"
|
|
6
|
+
require "time"
|
|
7
|
+
require_relative "pipeline_security"
|
|
8
|
+
require_relative "clp_scope"
|
|
9
|
+
require_relative "acl_scope"
|
|
10
|
+
|
|
11
|
+
module Parse
|
|
12
|
+
# Direct MongoDB access module for bypassing Parse Server.
|
|
13
|
+
# Provides read-only direct access to MongoDB for performance-critical queries.
|
|
14
|
+
#
|
|
15
|
+
# @example Enable direct MongoDB queries
|
|
16
|
+
# Parse::MongoDB.configure(
|
|
17
|
+
# uri: "mongodb://localhost:27017/parse",
|
|
18
|
+
# enabled: true
|
|
19
|
+
# )
|
|
20
|
+
#
|
|
21
|
+
# @example Using direct queries
|
|
22
|
+
# # Returns Parse objects, queried directly from MongoDB
|
|
23
|
+
# songs = Song.query(:plays.gt => 1000).results_direct
|
|
24
|
+
# first_song = Song.query(:plays.gt => 1000).first_direct
|
|
25
|
+
#
|
|
26
|
+
# == Field Name Conventions
|
|
27
|
+
#
|
|
28
|
+
# When writing aggregation pipelines for direct MongoDB queries, use MongoDB's native
|
|
29
|
+
# field naming conventions:
|
|
30
|
+
#
|
|
31
|
+
# - *Regular fields*: Use camelCase (e.g., +releaseDate+, +playCount+, +firstName+)
|
|
32
|
+
# - *Pointer fields*: Use +_p_+ prefix (e.g., +_p_author+, +_p_album+)
|
|
33
|
+
# - *Built-in dates*: Use +_created_at+ and +_updated_at+
|
|
34
|
+
# - *Field references*: Use +$fieldName+ syntax (e.g., +$releaseDate+, +$_p_author+)
|
|
35
|
+
#
|
|
36
|
+
# Results are automatically converted to Ruby-friendly format:
|
|
37
|
+
# - Field names converted to snake_case (+totalPlays+ → +total_plays+)
|
|
38
|
+
# - Custom aggregation results wrapped in +AggregationResult+ for method access
|
|
39
|
+
# - Parse documents returned as proper +Parse::Object+ instances
|
|
40
|
+
#
|
|
41
|
+
# @example Aggregation pipeline with MongoDB field names
|
|
42
|
+
# pipeline = [
|
|
43
|
+
# { "$match" => { "releaseDate" => { "$lt" => Time.now } } },
|
|
44
|
+
# { "$group" => { "_id" => "$_p_artist", "totalPlays" => { "$sum" => "$playCount" } } }
|
|
45
|
+
# ]
|
|
46
|
+
# results = Song.query.aggregate(pipeline, mongo_direct: true).results
|
|
47
|
+
#
|
|
48
|
+
# # Results use snake_case and support method access
|
|
49
|
+
# results.first.total_plays # => 5000
|
|
50
|
+
# results.first["totalPlays"] # => 5000 (original key also works)
|
|
51
|
+
#
|
|
52
|
+
# == Date Comparisons
|
|
53
|
+
#
|
|
54
|
+
# MongoDB stores dates in UTC. When comparing dates in aggregation pipelines:
|
|
55
|
+
# - Use Ruby +Time+ objects for comparisons (automatically converted to BSON dates)
|
|
56
|
+
# - Ruby +Date+ objects (without time) are stored as midnight UTC
|
|
57
|
+
# - For accurate date-only comparisons, use +Time.utc(year, month, day)+
|
|
58
|
+
#
|
|
59
|
+
# @example Date comparison in aggregation
|
|
60
|
+
# # Compare with a specific UTC time
|
|
61
|
+
# cutoff = Time.utc(2024, 1, 1, 0, 0, 0)
|
|
62
|
+
# pipeline = [{ "$match" => { "releaseDate" => { "$gte" => cutoff } } }]
|
|
63
|
+
#
|
|
64
|
+
# @example Using the date conversion helper
|
|
65
|
+
# # Safely convert any date/time to MongoDB-compatible UTC Time
|
|
66
|
+
# cutoff = Parse::MongoDB.to_mongodb_date(Date.new(2024, 1, 1)) # => Time UTC
|
|
67
|
+
# cutoff = Parse::MongoDB.to_mongodb_date("2024-01-01") # => Time UTC
|
|
68
|
+
# cutoff = Parse::MongoDB.to_mongodb_date(Time.now) # => Time UTC
|
|
69
|
+
#
|
|
70
|
+
# @note Requires the 'mongo' gem to be installed. Add to your Gemfile:
|
|
71
|
+
# gem 'mongo', '~> 2.18'
|
|
72
|
+
module MongoDB
|
|
73
|
+
# Error raised when mongo gem is not available
|
|
74
|
+
class GemNotAvailable < StandardError; end
|
|
75
|
+
|
|
76
|
+
# Error raised when direct MongoDB is not enabled
|
|
77
|
+
class NotEnabled < StandardError; end
|
|
78
|
+
|
|
79
|
+
# Error raised when MongoDB connection fails
|
|
80
|
+
class ConnectionError < StandardError; end
|
|
81
|
+
|
|
82
|
+
# Error raised when a denied operator is detected in a raw filter or
|
|
83
|
+
# pipeline forwarded through {Parse::MongoDB.find} or
|
|
84
|
+
# {Parse::MongoDB.aggregate}. Currently blocks $where, $function, and
|
|
85
|
+
# $accumulator, which all execute server-side JavaScript.
|
|
86
|
+
class DeniedOperator < StandardError; end
|
|
87
|
+
|
|
88
|
+
# Error raised when an index mutation primitive is invoked but the
|
|
89
|
+
# writer connection has not been configured via {.configure_writer}.
|
|
90
|
+
class WriterNotConfigured < StandardError; end
|
|
91
|
+
|
|
92
|
+
# Error raised when an index mutation primitive is invoked but one of
|
|
93
|
+
# the triple-gate conditions is not satisfied (writer URI configured
|
|
94
|
+
# AND `Parse::MongoDB.index_mutations_enabled = true` AND
|
|
95
|
+
# `ENV["PARSE_MONGO_INDEX_MUTATIONS"] == "1"`). The message names the
|
|
96
|
+
# missing gate so operators get an actionable error.
|
|
97
|
+
class MutationsDisabled < StandardError; end
|
|
98
|
+
|
|
99
|
+
# Error raised when an index mutation targets a Parse-internal
|
|
100
|
+
# collection (`_User`, `_Role`, `_Session`, etc.) without explicit
|
|
101
|
+
# `allow_system_classes: true` opt-in, or when the collection name
|
|
102
|
+
# fails the Parse-class regex.
|
|
103
|
+
class ForbiddenCollection < StandardError; end
|
|
104
|
+
|
|
105
|
+
# Error raised when {.configure_writer} validates the connected role
|
|
106
|
+
# and finds privileges that exceed `createIndex`/`dropIndex` + reads.
|
|
107
|
+
# The writer connection is meant strictly for index management; any
|
|
108
|
+
# role granting `insert`, `update`, `remove`, `dropCollection`, etc.
|
|
109
|
+
# is rejected fail-closed.
|
|
110
|
+
class WriterRoleTooPermissive < StandardError; end
|
|
111
|
+
|
|
112
|
+
# Error raised when MongoDB cancels a query because it exceeded the
|
|
113
|
+
# requested maxTimeMS budget (MongoDB error code 50 / MaxTimeMSExpired).
|
|
114
|
+
# This is the DB-side counterpart to {Parse::Agent::ToolTimeoutError} and
|
|
115
|
+
# is raised by {Parse::MongoDB.aggregate} / {Parse::MongoDB.find} when the
|
|
116
|
+
# driver reports code 50.
|
|
117
|
+
#
|
|
118
|
+
# @example Handling a DB-level timeout
|
|
119
|
+
# begin
|
|
120
|
+
# Parse::MongoDB.aggregate("Song", pipeline, max_time_ms: 5000)
|
|
121
|
+
# rescue Parse::MongoDB::ExecutionTimeout => e
|
|
122
|
+
# puts "#{e.collection_name} timed out after #{e.max_time_ms}ms"
|
|
123
|
+
# end
|
|
124
|
+
class ExecutionTimeout < StandardError
|
|
125
|
+
# @return [Integer] the maxTimeMS budget that was exceeded
|
|
126
|
+
attr_reader :max_time_ms
|
|
127
|
+
# @return [String] the collection that was being queried
|
|
128
|
+
attr_reader :collection_name
|
|
129
|
+
|
|
130
|
+
# @param collection_name [String] the MongoDB collection
|
|
131
|
+
# @param max_time_ms [Integer] the budget in milliseconds that was exceeded
|
|
132
|
+
def initialize(collection_name:, max_time_ms:)
|
|
133
|
+
@max_time_ms = max_time_ms
|
|
134
|
+
@collection_name = collection_name
|
|
135
|
+
super("Query on '#{collection_name}' exceeded max_time_ms=#{max_time_ms}ms — narrow filter or add index")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Threshold above which `Parse::MongoDB.find` emits a deprecation warning
|
|
140
|
+
# when called without an explicit `:limit` option. A future major release
|
|
141
|
+
# will enforce this as a hard default limit. Callers should pass an
|
|
142
|
+
# explicit `:limit` (including `:limit => 0` for unbounded) to silence the
|
|
143
|
+
# warning.
|
|
144
|
+
DEFAULT_FIND_LIMIT = 1000
|
|
145
|
+
|
|
146
|
+
# Environment variable names consulted (in priority order) when
|
|
147
|
+
# {.configure} is called without an explicit `uri:` argument.
|
|
148
|
+
# `ANALYTICS_DATABASE_URI` is listed first so deployments can point
|
|
149
|
+
# direct-read traffic at a dedicated analytics replica without
|
|
150
|
+
# disturbing the primary `DATABASE_URI` that Parse Server uses for
|
|
151
|
+
# writes. `DATABASE_URI` is the fallback for deployments where the
|
|
152
|
+
# direct path reads from the same node as Parse Server.
|
|
153
|
+
ENV_URI_KEYS = %w[ANALYTICS_DATABASE_URI DATABASE_URI].freeze
|
|
154
|
+
|
|
155
|
+
# Environment variable consulted as part of the triple gate for
|
|
156
|
+
# index mutations. The check is performed on every call (not just at
|
|
157
|
+
# configure time) so a SIGHUP / process-supervisor that flips the
|
|
158
|
+
# variable can revoke without restart.
|
|
159
|
+
MUTATION_ENV_KEY = "PARSE_MONGO_INDEX_MUTATIONS"
|
|
160
|
+
|
|
161
|
+
# Parse-internal collections that must not receive index mutations
|
|
162
|
+
# without explicit `allow_system_classes: true`. A unique index on
|
|
163
|
+
# `_Session.session_token`, for example, would break auth on the
|
|
164
|
+
# first duplicate token write.
|
|
165
|
+
PARSE_INTERNAL_CLASSES = %w[
|
|
166
|
+
_User _Role _Session _Installation _Audience _Idempotency
|
|
167
|
+
_PushStatus _JobStatus _Hooks _GlobalConfig _SCHEMA
|
|
168
|
+
].freeze
|
|
169
|
+
|
|
170
|
+
# Mongo privilege actions the writer role MAY hold. Anything outside
|
|
171
|
+
# this set causes {.configure_writer} to refuse with
|
|
172
|
+
# {WriterRoleTooPermissive}. Reads are allowed; mutations are
|
|
173
|
+
# scoped to index management only.
|
|
174
|
+
#
|
|
175
|
+
# The Atlas Search actions (`createSearchIndexes`, `dropSearchIndex`,
|
|
176
|
+
# `updateSearchIndex`, `listSearchIndexes`) are included so a writer
|
|
177
|
+
# role provisioned for search-index management passes the privilege
|
|
178
|
+
# probe. Operators who do not grant those actions in their Mongo role
|
|
179
|
+
# simply cannot invoke the search-index primitives — the SDK allowlist
|
|
180
|
+
# does not auto-grant; it only refuses to reject roles that legitimately
|
|
181
|
+
# hold these specific actions.
|
|
182
|
+
WRITER_ALLOWED_ACTIONS = %w[
|
|
183
|
+
createIndex dropIndex
|
|
184
|
+
createSearchIndexes dropSearchIndex updateSearchIndex listSearchIndexes
|
|
185
|
+
listIndexes listCollections collStats
|
|
186
|
+
find listDatabases connPoolStats serverStatus
|
|
187
|
+
].freeze
|
|
188
|
+
|
|
189
|
+
class << self
|
|
190
|
+
# @!attribute [rw] enabled
|
|
191
|
+
# Feature flag to enable/disable direct MongoDB queries.
|
|
192
|
+
# @return [Boolean]
|
|
193
|
+
attr_accessor :enabled
|
|
194
|
+
|
|
195
|
+
# @!attribute [rw] uri
|
|
196
|
+
# MongoDB connection URI.
|
|
197
|
+
# @return [String]
|
|
198
|
+
attr_accessor :uri
|
|
199
|
+
|
|
200
|
+
# @!attribute [rw] database
|
|
201
|
+
# MongoDB database name (extracted from URI or set manually).
|
|
202
|
+
# @return [String]
|
|
203
|
+
attr_accessor :database
|
|
204
|
+
|
|
205
|
+
# @!attribute [r] client
|
|
206
|
+
# The MongoDB client instance (memoized).
|
|
207
|
+
# @return [Mongo::Client]
|
|
208
|
+
attr_reader :client
|
|
209
|
+
|
|
210
|
+
# Check if the mongo gem is available
|
|
211
|
+
# @return [Boolean] true if mongo gem is loaded
|
|
212
|
+
def gem_available?
|
|
213
|
+
return @gem_available if defined?(@gem_available)
|
|
214
|
+
@gem_available = begin
|
|
215
|
+
require "mongo"
|
|
216
|
+
true
|
|
217
|
+
rescue LoadError
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Ensure mongo gem is loaded, raise error if not
|
|
223
|
+
# @raise [GemNotAvailable] if mongo gem is not installed
|
|
224
|
+
def require_gem!
|
|
225
|
+
return if gem_available?
|
|
226
|
+
raise GemNotAvailable,
|
|
227
|
+
"The 'mongo' gem is required for direct MongoDB queries. " \
|
|
228
|
+
"Add 'gem \"mongo\"' to your Gemfile and run 'bundle install'."
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Configure direct MongoDB access.
|
|
232
|
+
#
|
|
233
|
+
# When `uri:` is omitted, the value is resolved from the first
|
|
234
|
+
# environment variable in {ENV_URI_KEYS} that is set (so
|
|
235
|
+
# `ANALYTICS_DATABASE_URI` wins over `DATABASE_URI`). Raises
|
|
236
|
+
# `ArgumentError` if neither argument nor any env var supplied a URI.
|
|
237
|
+
#
|
|
238
|
+
# @param uri [String, nil] MongoDB connection URI. When nil, falls
|
|
239
|
+
# back to env-var resolution.
|
|
240
|
+
# @param enabled [Boolean] whether to enable direct queries (default: true)
|
|
241
|
+
# @param database [String, nil] database name (optional, extracted
|
|
242
|
+
# from URI if not provided)
|
|
243
|
+
# @param verify_role [Boolean] when true (the default), run a
|
|
244
|
+
# `connectionStatus` role check after configuring and emit a
|
|
245
|
+
# warning if the authenticated user appears to have write
|
|
246
|
+
# privileges. The direct path is read-only; a writeable role
|
|
247
|
+
# means a bug in the gem (or in caller code touching
|
|
248
|
+
# `Parse::MongoDB.client` directly) could write through it.
|
|
249
|
+
# Set to false to skip the check (no connection attempt during
|
|
250
|
+
# configure).
|
|
251
|
+
# @raise [ArgumentError] if no URI can be resolved
|
|
252
|
+
# @example Explicit URI
|
|
253
|
+
# Parse::MongoDB.configure(
|
|
254
|
+
# uri: "mongodb://user:pass@localhost:27017/parse?authSource=admin",
|
|
255
|
+
# enabled: true
|
|
256
|
+
# )
|
|
257
|
+
# @example Env-var resolution (ANALYTICS_DATABASE_URI preferred,
|
|
258
|
+
# falls back to DATABASE_URI)
|
|
259
|
+
# Parse::MongoDB.configure(enabled: true)
|
|
260
|
+
def configure(uri: nil, enabled: true, database: nil, verify_role: true)
|
|
261
|
+
require_gem!
|
|
262
|
+
resolved = uri || resolve_uri_from_env
|
|
263
|
+
if resolved.nil? || resolved.to_s.empty?
|
|
264
|
+
raise ArgumentError,
|
|
265
|
+
"Parse::MongoDB.configure requires a `uri:` argument or one of " \
|
|
266
|
+
"#{ENV_URI_KEYS.join(", ")} set in the environment."
|
|
267
|
+
end
|
|
268
|
+
@uri = resolved
|
|
269
|
+
@enabled = enabled
|
|
270
|
+
@database = database || extract_database_from_uri(resolved)
|
|
271
|
+
@client = nil # Reset client on reconfigure
|
|
272
|
+
warn_if_writeable_role! if verify_role && enabled
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# @return [String, nil] the first env-var URI found, in
|
|
276
|
+
# {ENV_URI_KEYS} priority order, or nil if none is set.
|
|
277
|
+
def resolve_uri_from_env
|
|
278
|
+
ENV_URI_KEYS.each do |key|
|
|
279
|
+
value = ENV[key]
|
|
280
|
+
return value if value && !value.empty?
|
|
281
|
+
end
|
|
282
|
+
nil
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Check if direct MongoDB queries are available and enabled
|
|
286
|
+
# @return [Boolean]
|
|
287
|
+
def available?
|
|
288
|
+
gem_available? && enabled? && uri.present?
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Check if direct queries are enabled
|
|
292
|
+
# @return [Boolean]
|
|
293
|
+
def enabled?
|
|
294
|
+
@enabled == true
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# MongoDB privilege "actions" that indicate write capability. Used by
|
|
298
|
+
# {.read_only?} to classify the authenticated user's role.
|
|
299
|
+
WRITE_ACTIONS = %w[
|
|
300
|
+
insert update remove
|
|
301
|
+
createCollection dropCollection
|
|
302
|
+
createIndex dropIndex
|
|
303
|
+
applyOps dropDatabase
|
|
304
|
+
renameCollectionSameDB enableSharding
|
|
305
|
+
].freeze
|
|
306
|
+
|
|
307
|
+
# Probe whether the authenticated user on the configured URI has any
|
|
308
|
+
# write privileges. Issues the `connectionStatus` command with
|
|
309
|
+
# `showPrivileges: true` — a read-only call that returns the user's
|
|
310
|
+
# role-derived privilege list.
|
|
311
|
+
#
|
|
312
|
+
# Return values:
|
|
313
|
+
# - `true` — user's privileges include no entries from {WRITE_ACTIONS}
|
|
314
|
+
# on the configured database. The role is observable read-only.
|
|
315
|
+
# - `false` — at least one write action was found.
|
|
316
|
+
# - `nil` — couldn't determine (no privilege list returned, command
|
|
317
|
+
# not supported, network failure). Treat as "unknown" — don't
|
|
318
|
+
# trust either answer.
|
|
319
|
+
#
|
|
320
|
+
# Caveats:
|
|
321
|
+
# - This is a ROLE check, not a transport check. A `readPreference=
|
|
322
|
+
# secondary` URI with a write-capable user is still write-capable;
|
|
323
|
+
# the driver routes writes to primary regardless of read preference.
|
|
324
|
+
# - Some MongoDB configurations restrict the user's visibility into
|
|
325
|
+
# their own privileges; an empty privilege list returns `nil`,
|
|
326
|
+
# not `true`.
|
|
327
|
+
# - Atlas Data Federation, BI Connector, and other non-standard
|
|
328
|
+
# endpoints may respond differently or refuse the command — also
|
|
329
|
+
# `nil`.
|
|
330
|
+
#
|
|
331
|
+
# @return [Boolean, nil]
|
|
332
|
+
def read_only?
|
|
333
|
+
return nil unless available?
|
|
334
|
+
result = client.database.command(connectionStatus: 1, showPrivileges: true).first
|
|
335
|
+
privileges = result && result.dig("authInfo", "authenticatedUserPrivileges")
|
|
336
|
+
return nil if privileges.nil? || privileges.empty?
|
|
337
|
+
write_set = WRITE_ACTIONS.to_set
|
|
338
|
+
has_write = privileges.any? do |priv|
|
|
339
|
+
Array(priv["actions"]).any? { |a| write_set.include?(a.to_s) }
|
|
340
|
+
end
|
|
341
|
+
!has_write
|
|
342
|
+
rescue StandardError
|
|
343
|
+
nil
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
# Emit a warning when {.read_only?} reports a writeable role. Called
|
|
347
|
+
# from {.configure} when `verify_role: true`. Silent on `true`
|
|
348
|
+
# (correctly read-only) and on `nil` (couldn't determine — too noisy
|
|
349
|
+
# to surface in normal operation).
|
|
350
|
+
# @api private
|
|
351
|
+
def warn_if_writeable_role!
|
|
352
|
+
case read_only?
|
|
353
|
+
when false
|
|
354
|
+
warn "[Parse::MongoDB] WARNING: the URI configured for direct " \
|
|
355
|
+
"queries authenticates a user with write privileges. The " \
|
|
356
|
+
"direct path is read-only by design; using a read-only " \
|
|
357
|
+
"role bounds the blast radius if caller code touches " \
|
|
358
|
+
"`Parse::MongoDB.client` directly. See " \
|
|
359
|
+
"docs/mongodb_direct_guide.md for routing direct reads at " \
|
|
360
|
+
"an analytics replica."
|
|
361
|
+
end
|
|
362
|
+
end
|
|
363
|
+
|
|
364
|
+
# Get or create the MongoDB client
|
|
365
|
+
# @return [Mongo::Client]
|
|
366
|
+
# @raise [GemNotAvailable] if mongo gem is not installed
|
|
367
|
+
# @raise [NotEnabled] if direct MongoDB is not enabled
|
|
368
|
+
# @raise [ConnectionError] if connection fails
|
|
369
|
+
def client
|
|
370
|
+
require_gem!
|
|
371
|
+
raise NotEnabled, "Direct MongoDB queries are not enabled. Call Parse::MongoDB.configure first." unless available?
|
|
372
|
+
|
|
373
|
+
@client ||= begin
|
|
374
|
+
::Mongo::Client.new(uri)
|
|
375
|
+
rescue => e
|
|
376
|
+
raise ConnectionError, "Failed to connect to MongoDB: #{e.message}"
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
|
|
380
|
+
# Reset the client connection (useful for testing)
|
|
381
|
+
def reset!
|
|
382
|
+
@client&.close rescue nil
|
|
383
|
+
@client = nil
|
|
384
|
+
@enabled = false
|
|
385
|
+
@uri = nil
|
|
386
|
+
@database = nil
|
|
387
|
+
reset_writer!
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
# Get a MongoDB collection
|
|
391
|
+
# @param name [String] the collection name
|
|
392
|
+
# @return [Mongo::Collection]
|
|
393
|
+
def collection(name)
|
|
394
|
+
client[name]
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
# Normalize a Parse-style read-preference value into the Mongo Ruby
|
|
398
|
+
# driver's `:mode` symbol. Accepts `nil` (returns `nil`), the five
|
|
399
|
+
# documented Parse strings (`PRIMARY`, `PRIMARY_PREFERRED`,
|
|
400
|
+
# `SECONDARY`, `SECONDARY_PREFERRED`, `NEAREST`) in any case with
|
|
401
|
+
# hyphens or underscores, and the equivalent symbol form. Unknown
|
|
402
|
+
# values produce a warning and return `nil` so the operation falls
|
|
403
|
+
# back to the client default rather than failing.
|
|
404
|
+
# @param value [String, Symbol, nil]
|
|
405
|
+
# @return [Symbol, nil]
|
|
406
|
+
def normalize_read_preference(value)
|
|
407
|
+
return nil if value.nil?
|
|
408
|
+
token = value.to_s.tr("-", "_").downcase
|
|
409
|
+
valid = %w[primary primary_preferred secondary secondary_preferred nearest].freeze
|
|
410
|
+
unless valid.include?(token)
|
|
411
|
+
warn "[Parse::MongoDB] Invalid read_preference #{value.inspect}; ignoring."
|
|
412
|
+
return nil
|
|
413
|
+
end
|
|
414
|
+
token.to_sym
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# ---- Writer connection (index mutations) -----------------------------
|
|
418
|
+
#
|
|
419
|
+
# The writer is a SECOND `Mongo::Client` configured against a
|
|
420
|
+
# write-capable Mongo role. It is intentionally distinct from the
|
|
421
|
+
# reader (`@client` above) so the existing analytics path keeps its
|
|
422
|
+
# read-only posture. The writer is reachable ONLY through the named
|
|
423
|
+
# primitives below — `create_index`, `drop_index`, `writer_indexes`.
|
|
424
|
+
# The underlying `Mongo::Client` is never returned to caller code,
|
|
425
|
+
# to bound blast radius if any in-process actor reaches one of the
|
|
426
|
+
# mutation methods. All mutations go through {.assert_mutations_allowed!}.
|
|
427
|
+
|
|
428
|
+
# @!attribute [rw] index_mutations_enabled
|
|
429
|
+
# Ruby-side gate (one of the three required for mutations). Default
|
|
430
|
+
# `false`. Must be flipped to `true` explicitly in code (typically
|
|
431
|
+
# in a rake task initializer, never in a web-process initializer).
|
|
432
|
+
# @return [Boolean]
|
|
433
|
+
attr_accessor :index_mutations_enabled
|
|
434
|
+
|
|
435
|
+
# Configure the writer connection used for index mutations.
|
|
436
|
+
# Opens a second `Mongo::Client` against `uri:`. The connection is
|
|
437
|
+
# validated via `connectionStatus` and rejected fail-closed if its
|
|
438
|
+
# role grants destructive privileges (insert/update/remove/
|
|
439
|
+
# dropCollection/dropDatabase/etc.). The client is stored privately
|
|
440
|
+
# and is not exposed through any public accessor.
|
|
441
|
+
#
|
|
442
|
+
# @param uri [String] writer URI, must be distinct from the reader
|
|
443
|
+
# `@uri`. Typically points at the same replica set with a different
|
|
444
|
+
# Mongo user holding only `createIndex`/`dropIndex` privileges.
|
|
445
|
+
# @param enabled [Boolean] when false, `configure_writer` records
|
|
446
|
+
# the URI but does NOT open the connection. Use this to lay
|
|
447
|
+
# wiring in code without activating the writer until a separate
|
|
448
|
+
# call sets `Parse::MongoDB.index_mutations_enabled = true`.
|
|
449
|
+
# @param verify_role [Boolean] when true (default), run the
|
|
450
|
+
# privilege check on the configured user and raise
|
|
451
|
+
# {WriterRoleTooPermissive} if it exceeds {WRITER_ALLOWED_ACTIONS}.
|
|
452
|
+
# Disable only in test fixtures.
|
|
453
|
+
# @raise [ArgumentError] when `uri:` is missing or matches the
|
|
454
|
+
# reader URI verbatim.
|
|
455
|
+
# @raise [WriterRoleTooPermissive] when the role check fails.
|
|
456
|
+
def configure_writer(uri:, enabled: true, verify_role: true)
|
|
457
|
+
require_gem!
|
|
458
|
+
raise ArgumentError, "configure_writer requires a uri:" if uri.nil? || uri.to_s.empty?
|
|
459
|
+
if @uri && @uri.to_s == uri.to_s
|
|
460
|
+
raise ArgumentError,
|
|
461
|
+
"configure_writer URI must differ from the reader URI. " \
|
|
462
|
+
"The writer is meant for a separately-credentialed Mongo role."
|
|
463
|
+
end
|
|
464
|
+
@writer_uri = uri
|
|
465
|
+
@writer_enabled = enabled
|
|
466
|
+
@writer_client&.close rescue nil
|
|
467
|
+
@writer_client = nil
|
|
468
|
+
if enabled
|
|
469
|
+
# Eagerly open so a misconfigured URI fails fast at configure time.
|
|
470
|
+
assert_writer_role_acceptable! if verify_role
|
|
471
|
+
end
|
|
472
|
+
end
|
|
473
|
+
|
|
474
|
+
# @return [Boolean] true when {.configure_writer} has been called
|
|
475
|
+
# with `enabled: true` and the connection is reachable.
|
|
476
|
+
def writer_configured?
|
|
477
|
+
!@writer_uri.nil? && @writer_enabled == true
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# @return [Boolean] true iff `ENV[MUTATION_ENV_KEY] == "1"`.
|
|
481
|
+
def mutations_env_enabled?
|
|
482
|
+
ENV[MUTATION_ENV_KEY].to_s == "1"
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Run all three gates. Returns nil on success; raises with a
|
|
486
|
+
# message naming the missing gate otherwise.
|
|
487
|
+
# @raise [WriterNotConfigured, MutationsDisabled]
|
|
488
|
+
def assert_mutations_allowed!
|
|
489
|
+
unless writer_configured?
|
|
490
|
+
raise WriterNotConfigured,
|
|
491
|
+
"Index mutations require Parse::MongoDB.configure_writer(uri: ...) " \
|
|
492
|
+
"to be called with a write-capable Mongo role URI distinct from the reader."
|
|
493
|
+
end
|
|
494
|
+
unless @index_mutations_enabled == true
|
|
495
|
+
raise MutationsDisabled,
|
|
496
|
+
"Index mutations are disabled. Set Parse::MongoDB.index_mutations_enabled = true " \
|
|
497
|
+
"explicitly (typically in a rake-task initializer, not in a web-process initializer)."
|
|
498
|
+
end
|
|
499
|
+
unless mutations_env_enabled?
|
|
500
|
+
raise MutationsDisabled,
|
|
501
|
+
"Index mutations require ENV[#{MUTATION_ENV_KEY.inspect}] == '1'. " \
|
|
502
|
+
"Set this only in environments where index mutations are intended " \
|
|
503
|
+
"(rake tasks, maintenance scripts), never on web/worker dynos."
|
|
504
|
+
end
|
|
505
|
+
nil
|
|
506
|
+
end
|
|
507
|
+
|
|
508
|
+
# Reset the writer connection and clear gate state. Called from
|
|
509
|
+
# {.reset!}; can be invoked directly for granular teardown.
|
|
510
|
+
def reset_writer!
|
|
511
|
+
@writer_client&.close rescue nil
|
|
512
|
+
@writer_client = nil
|
|
513
|
+
@writer_uri = nil
|
|
514
|
+
@writer_enabled = false
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
# Create an index on the named collection. Triple-gated; refuses
|
|
518
|
+
# Parse-internal collections unless `allow_system_classes: true`.
|
|
519
|
+
# Idempotent: if an index with identical key+options already exists,
|
|
520
|
+
# returns `:exists` without issuing the create.
|
|
521
|
+
#
|
|
522
|
+
# @param collection_name [String] target collection / Parse class
|
|
523
|
+
# @param keys [Hash{String,Symbol => Integer,String}] index key spec.
|
|
524
|
+
# Values are `1` (asc), `-1` (desc), `"2dsphere"`, `"text"`, `"hashed"`.
|
|
525
|
+
# @param name [String, nil] optional index name. When nil, Mongo
|
|
526
|
+
# generates `field_dir_field_dir` automatically.
|
|
527
|
+
# @param unique [Boolean] uniqueness constraint.
|
|
528
|
+
# @param sparse [Boolean] sparse index (skip docs missing the key).
|
|
529
|
+
# @param partial_filter [Hash, nil] partial index filter expression.
|
|
530
|
+
# @param expire_after [Integer, nil] TTL in seconds.
|
|
531
|
+
# @param allow_system_classes [Boolean] opt-in to mutate Parse-internal
|
|
532
|
+
# collections (`_User`, `_Role`, etc.). Default false. Audit-logged.
|
|
533
|
+
# @return [Symbol] `:created` on success, `:exists` when an
|
|
534
|
+
# identically-specified index was already present.
|
|
535
|
+
# @raise [WriterNotConfigured, MutationsDisabled, ForbiddenCollection]
|
|
536
|
+
def create_index(collection_name, keys, name: nil, unique: false, sparse: false,
|
|
537
|
+
partial_filter: nil, expire_after: nil, allow_system_classes: false)
|
|
538
|
+
assert_mutations_allowed!
|
|
539
|
+
assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
|
|
540
|
+
spec_keys = normalize_index_keys(keys)
|
|
541
|
+
existing = writer_indexes(collection_name, allow_system_classes: allow_system_classes)
|
|
542
|
+
if index_matches?(existing, spec_keys, name: name, unique: unique, sparse: sparse,
|
|
543
|
+
partial_filter: partial_filter, expire_after: expire_after)
|
|
544
|
+
audit_writer_event(:create_index_skipped, collection_name, keys: spec_keys, name: name)
|
|
545
|
+
return :exists
|
|
546
|
+
end
|
|
547
|
+
opts = build_index_options(name: name, unique: unique, sparse: sparse,
|
|
548
|
+
partial_filter: partial_filter, expire_after: expire_after)
|
|
549
|
+
audit_writer_event(:create_index, collection_name, keys: spec_keys, name: name, opts: opts)
|
|
550
|
+
writer_collection(collection_name).indexes.create_one(spec_keys, **opts)
|
|
551
|
+
:created
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Drop a named index. Requires the operator-supplied `confirm:`
|
|
555
|
+
# string to match `"drop:#{collection}:#{name}"` so a stale shell
|
|
556
|
+
# session against the wrong environment can't accidentally drop
|
|
557
|
+
# something via a rerun.
|
|
558
|
+
#
|
|
559
|
+
# @param collection_name [String] target collection
|
|
560
|
+
# @param name [String] index name to drop
|
|
561
|
+
# @param confirm [String] must equal `"drop:#{collection_name}:#{name}"`
|
|
562
|
+
# @param allow_system_classes [Boolean] opt-in for Parse-internal
|
|
563
|
+
# @return [Symbol] `:dropped` on success, `:absent` when the index
|
|
564
|
+
# did not exist (idempotent).
|
|
565
|
+
def drop_index(collection_name, name, confirm:, allow_system_classes: false)
|
|
566
|
+
assert_mutations_allowed!
|
|
567
|
+
assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
|
|
568
|
+
expected = "drop:#{collection_name}:#{name}"
|
|
569
|
+
unless confirm.to_s == expected
|
|
570
|
+
raise ArgumentError,
|
|
571
|
+
"drop_index confirmation mismatch. Pass confirm: #{expected.inspect} " \
|
|
572
|
+
"to drop #{name.inspect} from #{collection_name.inspect}."
|
|
573
|
+
end
|
|
574
|
+
existing = writer_indexes(collection_name, allow_system_classes: allow_system_classes)
|
|
575
|
+
unless existing.any? { |i| (i["name"] || i[:name]) == name }
|
|
576
|
+
audit_writer_event(:drop_index_absent, collection_name, name: name)
|
|
577
|
+
return :absent
|
|
578
|
+
end
|
|
579
|
+
audit_writer_event(:drop_index, collection_name, name: name)
|
|
580
|
+
writer_collection(collection_name).indexes.drop_one(name)
|
|
581
|
+
:dropped
|
|
582
|
+
end
|
|
583
|
+
|
|
584
|
+
# List indexes on a collection via the WRITER connection. Distinct
|
|
585
|
+
# from {.indexes} which uses the reader. Used by {.create_index}
|
|
586
|
+
# for the idempotency check so the existence read is performed on
|
|
587
|
+
# the same connection that will issue the create.
|
|
588
|
+
# @param collection_name [String]
|
|
589
|
+
# @param allow_system_classes [Boolean]
|
|
590
|
+
# @return [Array<Hash>]
|
|
591
|
+
def writer_indexes(collection_name, allow_system_classes: false)
|
|
592
|
+
assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
|
|
593
|
+
# NOTE: listing does not require the mutation gate — operators
|
|
594
|
+
# can inspect what's there even when mutations are disabled,
|
|
595
|
+
# which is useful for `parse:mongo:indexes:plan` dry-runs that
|
|
596
|
+
# don't intend to mutate.
|
|
597
|
+
unless writer_configured?
|
|
598
|
+
raise WriterNotConfigured,
|
|
599
|
+
"writer_indexes requires configure_writer to have been called."
|
|
600
|
+
end
|
|
601
|
+
begin
|
|
602
|
+
writer_collection(collection_name).indexes.to_a
|
|
603
|
+
rescue StandardError => e
|
|
604
|
+
# Mongo raises NamespaceNotFound (code 26) when the collection
|
|
605
|
+
# has not been created yet — listing indexes on a non-existent
|
|
606
|
+
# collection is "no indexes" from the SDK's perspective. Match
|
|
607
|
+
# by code AND by message substring because the driver's exact
|
|
608
|
+
# class path varies across versions.
|
|
609
|
+
return [] if mongo_namespace_not_found?(e)
|
|
610
|
+
raise
|
|
611
|
+
end
|
|
612
|
+
end
|
|
613
|
+
|
|
614
|
+
# List Atlas Search indexes via the WRITER connection. Distinct
|
|
615
|
+
# from {.list_search_indexes} which uses the reader's aggregate
|
|
616
|
+
# path. Used by the search-index mutation primitives below for the
|
|
617
|
+
# existence check so the read is performed on the same connection
|
|
618
|
+
# that will issue the mutation. Returns `[]` for collections that
|
|
619
|
+
# do not yet exist.
|
|
620
|
+
#
|
|
621
|
+
# @param collection_name [String]
|
|
622
|
+
# @param allow_system_classes [Boolean]
|
|
623
|
+
# @return [Array<Hash>] raw search-index documents
|
|
624
|
+
# @raise [WriterNotConfigured, ForbiddenCollection]
|
|
625
|
+
def writer_search_indexes(collection_name, allow_system_classes: false)
|
|
626
|
+
assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
|
|
627
|
+
unless writer_configured?
|
|
628
|
+
raise WriterNotConfigured,
|
|
629
|
+
"writer_search_indexes requires configure_writer to have been called."
|
|
630
|
+
end
|
|
631
|
+
begin
|
|
632
|
+
writer_collection(collection_name)
|
|
633
|
+
.aggregate([{ "$listSearchIndexes" => {} }]).to_a
|
|
634
|
+
rescue StandardError => e
|
|
635
|
+
return [] if mongo_namespace_not_found?(e)
|
|
636
|
+
raise
|
|
637
|
+
end
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
# Create an Atlas Search index. Triple-gated like {.create_index};
|
|
641
|
+
# refuses Parse-internal collections unless `allow_system_classes:
|
|
642
|
+
# true`. Idempotent on name: if a search index with the same name
|
|
643
|
+
# already exists, returns `:exists` without issuing the create.
|
|
644
|
+
# The mapping definition of the existing index is NOT diffed — use
|
|
645
|
+
# {.update_search_index} to change a definition.
|
|
646
|
+
#
|
|
647
|
+
# The build runs ASYNCHRONOUSLY on the Atlas Search node. This
|
|
648
|
+
# method returns as soon as the command is accepted; the index is
|
|
649
|
+
# not queryable until its status transitions to `READY`. Poll
|
|
650
|
+
# {Parse::AtlasSearch::IndexManager.index_ready?} to confirm.
|
|
651
|
+
#
|
|
652
|
+
# @param collection_name [String] target collection / Parse class
|
|
653
|
+
# @param name [String] the search index name. Must match
|
|
654
|
+
# `/\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/`.
|
|
655
|
+
# @param definition [Hash] the search index definition (e.g.
|
|
656
|
+
# `{ mappings: { dynamic: true } }`). String/symbol keys both
|
|
657
|
+
# accepted; converted to string keys before submission.
|
|
658
|
+
# @param allow_system_classes [Boolean] opt-in to mutate Parse-
|
|
659
|
+
# internal collections. Default false. Audit-logged.
|
|
660
|
+
# @return [Symbol] `:created` on submission, `:exists` when a
|
|
661
|
+
# search index with that name already exists.
|
|
662
|
+
# @raise [WriterNotConfigured, MutationsDisabled, ForbiddenCollection, ArgumentError]
|
|
663
|
+
def create_search_index(collection_name, name, definition, allow_system_classes: false)
|
|
664
|
+
assert_mutations_allowed!
|
|
665
|
+
assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
|
|
666
|
+
validate_search_index_name!(name)
|
|
667
|
+
validate_search_index_definition!(definition)
|
|
668
|
+
existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes)
|
|
669
|
+
if existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s }
|
|
670
|
+
audit_writer_event(:create_search_index_skipped, collection_name, name: name)
|
|
671
|
+
return :exists
|
|
672
|
+
end
|
|
673
|
+
audit_writer_event(:create_search_index, collection_name, name: name)
|
|
674
|
+
writer_client.database.command(
|
|
675
|
+
createSearchIndexes: collection_name.to_s,
|
|
676
|
+
indexes: [{ name: name.to_s, definition: stringify_keys_deep(definition) }],
|
|
677
|
+
)
|
|
678
|
+
:created
|
|
679
|
+
end
|
|
680
|
+
|
|
681
|
+
# Drop a named Atlas Search index. Requires the operator-supplied
|
|
682
|
+
# `confirm:` string to match `"drop_search:#{collection}:#{name}"`.
|
|
683
|
+
# The token deliberately differs from {.drop_index}'s `"drop:"`
|
|
684
|
+
# prefix so a token meant for a regular index cannot be replayed
|
|
685
|
+
# against a search index with the same name (and vice versa).
|
|
686
|
+
#
|
|
687
|
+
# The drop is asynchronous on the Atlas Search node but typically
|
|
688
|
+
# completes quickly; the local cache in
|
|
689
|
+
# {Parse::AtlasSearch::IndexManager} should be invalidated by the
|
|
690
|
+
# caller (the IndexManager wrapper does this).
|
|
691
|
+
#
|
|
692
|
+
# @param collection_name [String] target collection
|
|
693
|
+
# @param name [String] search index name to drop
|
|
694
|
+
# @param confirm [String] must equal
|
|
695
|
+
# `"drop_search:#{collection_name}:#{name}"`
|
|
696
|
+
# @param allow_system_classes [Boolean] opt-in for Parse-internal
|
|
697
|
+
# @return [Symbol] `:dropped` on success, `:absent` when no such
|
|
698
|
+
# search index existed (idempotent).
|
|
699
|
+
# @raise [WriterNotConfigured, MutationsDisabled, ForbiddenCollection, ArgumentError]
|
|
700
|
+
def drop_search_index(collection_name, name, confirm:, allow_system_classes: false)
|
|
701
|
+
assert_mutations_allowed!
|
|
702
|
+
assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
|
|
703
|
+
expected = "drop_search:#{collection_name}:#{name}"
|
|
704
|
+
unless confirm.to_s == expected
|
|
705
|
+
raise ArgumentError,
|
|
706
|
+
"drop_search_index confirmation mismatch. Pass confirm: #{expected.inspect} " \
|
|
707
|
+
"to drop search index #{name.inspect} from #{collection_name.inspect}."
|
|
708
|
+
end
|
|
709
|
+
existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes)
|
|
710
|
+
unless existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s }
|
|
711
|
+
audit_writer_event(:drop_search_index_absent, collection_name, name: name)
|
|
712
|
+
return :absent
|
|
713
|
+
end
|
|
714
|
+
audit_writer_event(:drop_search_index, collection_name, name: name)
|
|
715
|
+
writer_client.database.command(
|
|
716
|
+
dropSearchIndex: collection_name.to_s,
|
|
717
|
+
name: name.to_s,
|
|
718
|
+
)
|
|
719
|
+
:dropped
|
|
720
|
+
end
|
|
721
|
+
|
|
722
|
+
# Replace the definition of an existing Atlas Search index. The
|
|
723
|
+
# rebuild runs asynchronously on the Atlas Search node; the new
|
|
724
|
+
# mapping is not live until the index's status transitions back to
|
|
725
|
+
# `READY`. Poll {Parse::AtlasSearch::IndexManager.index_ready?}
|
|
726
|
+
# to confirm.
|
|
727
|
+
#
|
|
728
|
+
# Raises `ArgumentError` if no search index with that name exists
|
|
729
|
+
# — use {.create_search_index} for new indexes. The mapping diff
|
|
730
|
+
# is not computed; the command is issued unconditionally for
|
|
731
|
+
# existing indexes (Atlas itself handles "definition unchanged"
|
|
732
|
+
# cases gracefully).
|
|
733
|
+
#
|
|
734
|
+
# @param collection_name [String]
|
|
735
|
+
# @param name [String] existing search index name
|
|
736
|
+
# @param definition [Hash] replacement definition
|
|
737
|
+
# @param allow_system_classes [Boolean]
|
|
738
|
+
# @return [Symbol] `:updated` on submission
|
|
739
|
+
# @raise [WriterNotConfigured, MutationsDisabled, ForbiddenCollection, ArgumentError]
|
|
740
|
+
def update_search_index(collection_name, name, definition, allow_system_classes: false)
|
|
741
|
+
assert_mutations_allowed!
|
|
742
|
+
assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes)
|
|
743
|
+
validate_search_index_name!(name)
|
|
744
|
+
validate_search_index_definition!(definition)
|
|
745
|
+
existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes)
|
|
746
|
+
unless existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s }
|
|
747
|
+
audit_writer_event(:update_search_index_absent, collection_name, name: name)
|
|
748
|
+
raise ArgumentError,
|
|
749
|
+
"update_search_index: no Atlas Search index named #{name.inspect} " \
|
|
750
|
+
"on collection #{collection_name.inspect}. Use create_search_index to create one."
|
|
751
|
+
end
|
|
752
|
+
audit_writer_event(:update_search_index, collection_name, name: name)
|
|
753
|
+
writer_client.database.command(
|
|
754
|
+
updateSearchIndex: collection_name.to_s,
|
|
755
|
+
name: name.to_s,
|
|
756
|
+
definition: stringify_keys_deep(definition),
|
|
757
|
+
)
|
|
758
|
+
:updated
|
|
759
|
+
end
|
|
760
|
+
|
|
761
|
+
private
|
|
762
|
+
|
|
763
|
+
# The active writer collection handle. Private — never exposed in
|
|
764
|
+
# a public accessor. The only sites that hold a `Mongo::Collection`
|
|
765
|
+
# from the writer are the mutation methods above.
|
|
766
|
+
def writer_collection(name)
|
|
767
|
+
writer_client[name]
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def writer_client
|
|
771
|
+
require_gem!
|
|
772
|
+
unless writer_configured?
|
|
773
|
+
raise WriterNotConfigured,
|
|
774
|
+
"Writer is not configured. Call Parse::MongoDB.configure_writer(uri:) first."
|
|
775
|
+
end
|
|
776
|
+
@writer_client ||= begin
|
|
777
|
+
# min_pool_size: 0 — keep idle pool drained when not in use.
|
|
778
|
+
# The writer should be a rare-use connection.
|
|
779
|
+
::Mongo::Client.new(@writer_uri, min_pool_size: 0, max_pool_size: 2,
|
|
780
|
+
server_selection_timeout: 10,
|
|
781
|
+
socket_timeout: 10,
|
|
782
|
+
connect_timeout: 5,
|
|
783
|
+
monitoring: false)
|
|
784
|
+
rescue => e
|
|
785
|
+
raise ConnectionError, "Failed to connect writer client: #{e.message}"
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
|
|
789
|
+
def assert_writer_role_acceptable!
|
|
790
|
+
result = writer_client.database.command(connectionStatus: 1, showPrivileges: true).first
|
|
791
|
+
privileges = result && result.dig("authInfo", "authenticatedUserPrivileges")
|
|
792
|
+
if privileges.nil?
|
|
793
|
+
# Can't verify — fail closed for the writer (the reader can
|
|
794
|
+
# tolerate :unknown, the writer cannot).
|
|
795
|
+
raise WriterRoleTooPermissive,
|
|
796
|
+
"Could not verify writer role privileges (connectionStatus returned no privilege list). " \
|
|
797
|
+
"Writer must be explicitly bound to a role granting only #{WRITER_ALLOWED_ACTIONS.inspect}."
|
|
798
|
+
end
|
|
799
|
+
allowed = WRITER_ALLOWED_ACTIONS.to_set
|
|
800
|
+
actions_seen = privileges.flat_map { |p| Array(p["actions"]) }.map(&:to_s).uniq
|
|
801
|
+
extras = actions_seen.reject { |a| allowed.include?(a) }
|
|
802
|
+
unless extras.empty?
|
|
803
|
+
raise WriterRoleTooPermissive,
|
|
804
|
+
"Writer role grants disallowed actions: #{extras.inspect}. " \
|
|
805
|
+
"Writer must be bound to a role granting only #{WRITER_ALLOWED_ACTIONS.inspect}. " \
|
|
806
|
+
"Create a dedicated Mongo user with the parse_index_admin role pattern."
|
|
807
|
+
end
|
|
808
|
+
nil
|
|
809
|
+
end
|
|
810
|
+
|
|
811
|
+
def assert_collection_allowed!(collection_name, allow_system_classes:)
|
|
812
|
+
name = collection_name.to_s
|
|
813
|
+
# Parse-internal classes start with `_` (e.g. `_User`, `_Role`).
|
|
814
|
+
# Parse Relation join collections are `_Join:<field>:<ParentClass>`
|
|
815
|
+
# where the parent class may itself start with `_` (e.g. the
|
|
816
|
+
# canonical `Parse::Role.users` relation → `_Join:users:_Role`).
|
|
817
|
+
# Allow both shapes here; the dedicated denylist below produces
|
|
818
|
+
# the clearer error for top-level Parse-internal names.
|
|
819
|
+
unless name.match?(/\A(_?[A-Za-z][A-Za-z0-9_]*|_Join:[A-Za-z][A-Za-z0-9_]*:_?[A-Za-z][A-Za-z0-9_]*)\z/)
|
|
820
|
+
raise ForbiddenCollection,
|
|
821
|
+
"Collection name #{name.inspect} must be either a Parse class " \
|
|
822
|
+
"(matches /\\A_?[A-Za-z][A-Za-z0-9_]*\\z/) or a Parse Relation " \
|
|
823
|
+
"join collection (matches /\\A_Join:<field>:<ParentClass>\\z/)."
|
|
824
|
+
end
|
|
825
|
+
if PARSE_INTERNAL_CLASSES.include?(name) && !allow_system_classes
|
|
826
|
+
raise ForbiddenCollection,
|
|
827
|
+
"Index mutations against Parse-internal collection #{name.inspect} are forbidden. " \
|
|
828
|
+
"Pass allow_system_classes: true to opt in (audit-logged at WARN)."
|
|
829
|
+
end
|
|
830
|
+
nil
|
|
831
|
+
end
|
|
832
|
+
|
|
833
|
+
def normalize_index_keys(keys)
|
|
834
|
+
unless keys.is_a?(Hash) && !keys.empty?
|
|
835
|
+
raise ArgumentError, "Index keys must be a non-empty Hash like { field: 1 }; got #{keys.inspect}"
|
|
836
|
+
end
|
|
837
|
+
keys.each_with_object({}) do |(field, dir), h|
|
|
838
|
+
h[field.to_s] = dir
|
|
839
|
+
end
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
def build_index_options(name:, unique:, sparse:, partial_filter:, expire_after:)
|
|
843
|
+
opts = {}
|
|
844
|
+
opts[:name] = name if name
|
|
845
|
+
opts[:unique] = true if unique
|
|
846
|
+
opts[:sparse] = true if sparse
|
|
847
|
+
opts[:partial_filter_expression] = partial_filter if partial_filter
|
|
848
|
+
opts[:expire_after] = expire_after if expire_after
|
|
849
|
+
opts
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# Whether the existing index list contains an entry matching the
|
|
853
|
+
# requested spec. Compared by key signature first (canonical
|
|
854
|
+
# ordering), then by the small set of options that meaningfully
|
|
855
|
+
# change index semantics (`unique`, `sparse`, `partialFilterExpression`,
|
|
856
|
+
# `expireAfterSeconds`). When `name:` is supplied, the existing
|
|
857
|
+
# index's name must also match.
|
|
858
|
+
def index_matches?(existing, keys, name:, unique:, sparse:, partial_filter:, expire_after:)
|
|
859
|
+
existing.any? do |idx|
|
|
860
|
+
ex_keys = stringify_keys(idx["key"] || idx[:key])
|
|
861
|
+
next false unless ex_keys == stringify_keys(keys)
|
|
862
|
+
next false if name && (idx["name"] || idx[:name]) != name
|
|
863
|
+
next false if !!unique != (idx["unique"] == true)
|
|
864
|
+
next false if !!sparse != (idx["sparse"] == true)
|
|
865
|
+
next false if (partial_filter || nil) != (idx["partialFilterExpression"] || nil)
|
|
866
|
+
next false if expire_after && idx["expireAfterSeconds"] != expire_after
|
|
867
|
+
true
|
|
868
|
+
end
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
def stringify_keys(hash)
|
|
872
|
+
return {} if hash.nil?
|
|
873
|
+
hash.each_with_object({}) { |(k, v), h| h[k.to_s] = v }
|
|
874
|
+
end
|
|
875
|
+
|
|
876
|
+
# MongoDB raises NamespaceNotFound (code 26) when `listIndexes`
|
|
877
|
+
# runs against a collection that does not exist yet. Match by
|
|
878
|
+
# error code AND by message substring — the driver class path
|
|
879
|
+
# for `Mongo::Error::OperationFailure` is stable but the response-
|
|
880
|
+
# parsing path that surfaces the code has varied across versions.
|
|
881
|
+
def mongo_namespace_not_found?(err)
|
|
882
|
+
return true if err.respond_to?(:code) && err.code == 26
|
|
883
|
+
msg = err.message.to_s
|
|
884
|
+
msg.include?("NamespaceNotFound") || msg.include?("ns does not exist")
|
|
885
|
+
end
|
|
886
|
+
|
|
887
|
+
# Emit a structured audit line for writer events. Matches the
|
|
888
|
+
# `[Parse::*:SECURITY]` warn-line style used elsewhere in the gem.
|
|
889
|
+
def audit_writer_event(event, collection_name, **fields)
|
|
890
|
+
payload = fields.map { |k, v| "#{k}=#{v.inspect}" }.join(" ")
|
|
891
|
+
warn "[Parse::MongoDB:WRITER] event=#{event} collection=#{collection_name.inspect} " \
|
|
892
|
+
"pid=#{Process.pid} #{payload}"
|
|
893
|
+
end
|
|
894
|
+
|
|
895
|
+
# Atlas Search index names share the URL/path space with Mongo
|
|
896
|
+
# commands; constrain them to a conservative identifier shape to
|
|
897
|
+
# avoid surprises from operators pasting whitespace, slashes, or
|
|
898
|
+
# control characters into a definition file.
|
|
899
|
+
def validate_search_index_name!(name)
|
|
900
|
+
s = name.to_s
|
|
901
|
+
unless s.match?(/\A[A-Za-z][A-Za-z0-9_-]{0,63}\z/)
|
|
902
|
+
raise ArgumentError,
|
|
903
|
+
"Atlas Search index name #{name.inspect} is invalid. " \
|
|
904
|
+
"Must match /\\A[A-Za-z][A-Za-z0-9_-]{0,63}\\z/."
|
|
905
|
+
end
|
|
906
|
+
end
|
|
907
|
+
|
|
908
|
+
def validate_search_index_definition!(definition)
|
|
909
|
+
unless definition.is_a?(Hash) && !definition.empty?
|
|
910
|
+
raise ArgumentError,
|
|
911
|
+
"Atlas Search index definition must be a non-empty Hash; got #{definition.inspect}"
|
|
912
|
+
end
|
|
913
|
+
end
|
|
914
|
+
|
|
915
|
+
# Mongo's command parser tolerates symbol keys at the top level of
|
|
916
|
+
# the command Hash, but nested driver serialization for arbitrary
|
|
917
|
+
# mapping shapes (e.g. `fields: { title: { type: "string" } }`) is
|
|
918
|
+
# safer with string keys throughout. Mirrors {#stringify_keys} but
|
|
919
|
+
# recurses into Arrays and Hashes.
|
|
920
|
+
def stringify_keys_deep(value)
|
|
921
|
+
case value
|
|
922
|
+
when Hash
|
|
923
|
+
value.each_with_object({}) { |(k, v), h| h[k.to_s] = stringify_keys_deep(v) }
|
|
924
|
+
when Array
|
|
925
|
+
value.map { |v| stringify_keys_deep(v) }
|
|
926
|
+
else
|
|
927
|
+
value
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
|
|
931
|
+
public
|
|
932
|
+
|
|
933
|
+
# Re-expose `collection` as public after the private block above.
|
|
934
|
+
# (Ruby's `private` is sticky to the end of the class body; the
|
|
935
|
+
# writer-internal methods above are intentionally private but
|
|
936
|
+
# `collection` and the existing public surface must remain public.)
|
|
937
|
+
#
|
|
938
|
+
# No-op marker — the actual `public` reset happens below by
|
|
939
|
+
# explicitly listing the methods to re-publish.
|
|
940
|
+
|
|
941
|
+
# @deprecated Retained for backwards compatibility. The canonical list now lives
|
|
942
|
+
# in {Parse::PipelineSecurity::DENIED_OPERATORS}.
|
|
943
|
+
DENIED_OPERATORS = Parse::PipelineSecurity::DENIED_OPERATORS
|
|
944
|
+
|
|
945
|
+
# @!visibility private
|
|
946
|
+
# Default BFS depth for role-graph expansion. Real-world role graphs
|
|
947
|
+
# are 2-4 deep; 6 leaves headroom for unusual hierarchies without
|
|
948
|
+
# encouraging runaway $graphLookup fan-out on pathological inputs.
|
|
949
|
+
ROLE_GRAPH_DEFAULT_DEPTH = 6
|
|
950
|
+
|
|
951
|
+
# @!visibility private
|
|
952
|
+
# Hard ceiling on accepted `max_depth:` for the role-graph helpers.
|
|
953
|
+
# Anything above raises `ArgumentError` — the helpers do not silently
|
|
954
|
+
# clamp because a caller passing 100 is a bug worth surfacing.
|
|
955
|
+
# Lowered from 20 to 6 (matches DEFAULT_DEPTH) to prevent the helper
|
|
956
|
+
# from being used as a `$graphLookup` DoS amplifier on pathological
|
|
957
|
+
# role hierarchies. Real-world Parse `_Role` graphs are 2-4 deep;
|
|
958
|
+
# callers needing more should examine why their hierarchy is so
|
|
959
|
+
# deep before raising this ceiling.
|
|
960
|
+
ROLE_GRAPH_MAX_DEPTH = 6
|
|
961
|
+
|
|
962
|
+
# @!visibility private
|
|
963
|
+
# Hardcoded `maxTimeMS` budget for the role-graph aggregations. Both
|
|
964
|
+
# the forward (user → roles) and reverse (role → users) helpers run
|
|
965
|
+
# under this cap; an attacker who synthesizes a deep / fan-out-heavy
|
|
966
|
+
# role graph cannot extend execution beyond this budget.
|
|
967
|
+
ROLE_GRAPH_MAX_TIME_MS = 5000
|
|
968
|
+
|
|
969
|
+
# @!visibility private
|
|
970
|
+
# Strict regex for Parse objectIds passed into the role-graph helpers.
|
|
971
|
+
# Parse Server's default IDs are 10 alphanumeric chars; configurable
|
|
972
|
+
# custom-ID rules permit `_`/`-` and lengths up to 64. The regex fails
|
|
973
|
+
# closed on NUL bytes, Unicode RTL marks, dotted forms, etc.
|
|
974
|
+
ROLE_GRAPH_ID_RE = /\A[A-Za-z0-9_\-]{1,64}\z/
|
|
975
|
+
|
|
976
|
+
# Resolve every role name a user inherits via a single
|
|
977
|
+
# `$graphLookup` aggregation against the Parse role-membership and
|
|
978
|
+
# role-inheritance join tables.
|
|
979
|
+
#
|
|
980
|
+
# This is the mongo-direct fast path that {Parse::Role.all_for_user}
|
|
981
|
+
# falls into when an explicit authorization scope is provided.
|
|
982
|
+
# The pipeline shape is hardcoded; only `user_id` and `max_depth`
|
|
983
|
+
# are interpolated, and both are validated against {ROLE_GRAPH_ID_RE}
|
|
984
|
+
# / {ROLE_GRAPH_MAX_DEPTH}.
|
|
985
|
+
#
|
|
986
|
+
# The call bypasses {Parse::MongoDB.aggregate} on purpose: that
|
|
987
|
+
# entry point injects an `_rperm` `$match` and rewrites
|
|
988
|
+
# `$lookup` / `$graphLookup` stages with the same predicate, which
|
|
989
|
+
# would filter every `_Join:*:_Role` row to zero (those join
|
|
990
|
+
# collections have no `_rperm` column). {Parse::PipelineSecurity.validate_filter!}
|
|
991
|
+
# still runs against the constructed pipeline as belt-and-braces
|
|
992
|
+
# protection against a future regression that interpolates a caller
|
|
993
|
+
# value into a denied operator.
|
|
994
|
+
#
|
|
995
|
+
# If `_Join:roles:_Role` doesn't exist (the app uses flat roles
|
|
996
|
+
# without inheritance), MongoDB treats the missing collection as
|
|
997
|
+
# empty and `$graphLookup` returns no parents — the result collapses
|
|
998
|
+
# to direct memberships only, matching the Parse-Server-backed walk.
|
|
999
|
+
#
|
|
1000
|
+
# ## Authorization contract
|
|
1001
|
+
#
|
|
1002
|
+
# The helper requires an EXPLICIT per-call authorization:
|
|
1003
|
+
#
|
|
1004
|
+
# * `master: true` — explicit master-mode opt-in. Bypasses
|
|
1005
|
+
# `_Role` CLP. Use for admin tooling, analytics jobs, and
|
|
1006
|
+
# any code path that legitimately needs to read role graphs
|
|
1007
|
+
# across users.
|
|
1008
|
+
#
|
|
1009
|
+
# * `as: <User|Pointer>` — caller scope. The supplied user must
|
|
1010
|
+
# be permitted to `find` on `_Role` under the cached CLP, or
|
|
1011
|
+
# {Parse::CLPScope::Denied} is raised. `_Role`'s default CLP
|
|
1012
|
+
# is master-only, so this path will fail closed unless the
|
|
1013
|
+
# operator has explicitly opened `_Role` CLP for the user.
|
|
1014
|
+
#
|
|
1015
|
+
# Passing neither raises `ArgumentError`. The previous behavior
|
|
1016
|
+
# (gated only on the process-level `master_key_available?`
|
|
1017
|
+
# boolean — a check on the SDK's boot config, not the caller's
|
|
1018
|
+
# authority) is removed — it provided no per-call authorization.
|
|
1019
|
+
#
|
|
1020
|
+
# ## Return-value contract
|
|
1021
|
+
# - `Set<String>` on success (possibly empty if the user has no
|
|
1022
|
+
# direct memberships).
|
|
1023
|
+
# - `nil` when the fast path is unavailable (mongo gem missing,
|
|
1024
|
+
# {Parse::MongoDB.available?} false). Callers fall back to the
|
|
1025
|
+
# Parse-Server N+1 walk.
|
|
1026
|
+
# - Raises {Parse::MongoDB::ExecutionTimeout} on Mongo timeout
|
|
1027
|
+
# (attack-signal — do not silently fall back), `ArgumentError`
|
|
1028
|
+
# on input-validation failure or missing authorization, and
|
|
1029
|
+
# propagates other `Mongo::Error` subclasses that aren't
|
|
1030
|
+
# recognized as benign availability errors.
|
|
1031
|
+
#
|
|
1032
|
+
# @param user_id [String] a Parse `_User.objectId`.
|
|
1033
|
+
# @param max_depth [Integer] BFS depth bound. See
|
|
1034
|
+
# {ROLE_GRAPH_DEFAULT_DEPTH} for the default and
|
|
1035
|
+
# {ROLE_GRAPH_MAX_DEPTH} for the upper bound.
|
|
1036
|
+
# @param master [Boolean] when `true`, bypass `_Role` CLP. Mutually
|
|
1037
|
+
# exclusive with `as:`.
|
|
1038
|
+
# @param as [Parse::User, Parse::Pointer, nil] caller-scope user.
|
|
1039
|
+
# When provided (and `master:` is not), the scope is resolved
|
|
1040
|
+
# via {Parse::ACLScope.resolve!} and the resulting permission
|
|
1041
|
+
# set is checked against `_Role` CLP before the pipeline runs.
|
|
1042
|
+
# @return [Set<String>, nil] resolved role names, or nil when the
|
|
1043
|
+
# fast path is unavailable.
|
|
1044
|
+
# @raise [ArgumentError] when neither `master:` nor `as:` is
|
|
1045
|
+
# supplied, or when both are supplied.
|
|
1046
|
+
# @raise [Parse::CLPScope::Denied] when `as:` is supplied and the
|
|
1047
|
+
# scope cannot `find` on `_Role`.
|
|
1048
|
+
def role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil)
|
|
1049
|
+
authorize_role_graph_call!(:role_names_for_user, master: master, as: as)
|
|
1050
|
+
validate_role_graph_id!(user_id, "user_id")
|
|
1051
|
+
depth = validate_role_graph_depth!(max_depth)
|
|
1052
|
+
return Set.new if depth <= 0
|
|
1053
|
+
return nil unless available?
|
|
1054
|
+
|
|
1055
|
+
graph_depth = depth - 1
|
|
1056
|
+
pipeline = build_user_role_names_pipeline(user_id, graph_depth)
|
|
1057
|
+
Parse::PipelineSecurity.validate_filter!(
|
|
1058
|
+
pipeline, allow_internal_fields: true,
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
result_set = nil
|
|
1062
|
+
ActiveSupport::Notifications.instrument(
|
|
1063
|
+
"parse.mongodb.role_graph",
|
|
1064
|
+
direction: :forward, target_id: user_id, depth: depth,
|
|
1065
|
+
) do |payload|
|
|
1066
|
+
docs = collection("_Join:users:_Role").aggregate(
|
|
1067
|
+
pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS,
|
|
1068
|
+
).to_a
|
|
1069
|
+
names = Array(docs.first && docs.first["names"])
|
|
1070
|
+
result_set = Set.new(
|
|
1071
|
+
names.reject { |n| n.nil? || n.to_s.empty? }.map(&:to_s),
|
|
1072
|
+
)
|
|
1073
|
+
payload[:result_count] = result_set.size
|
|
1074
|
+
end
|
|
1075
|
+
result_set
|
|
1076
|
+
rescue NotEnabled, GemNotAvailable
|
|
1077
|
+
nil
|
|
1078
|
+
rescue StandardError => e
|
|
1079
|
+
if defined?(::Mongo::Error::OperationFailure) &&
|
|
1080
|
+
e.is_a?(::Mongo::Error::OperationFailure)
|
|
1081
|
+
raise_if_timeout!(e, "_Join:users:_Role", ROLE_GRAPH_MAX_TIME_MS)
|
|
1082
|
+
end
|
|
1083
|
+
raise
|
|
1084
|
+
end
|
|
1085
|
+
|
|
1086
|
+
# Resolve every `_User.objectId` whose effective role set includes
|
|
1087
|
+
# `role_id` — i.e., direct members of `role_id` PLUS direct members
|
|
1088
|
+
# of any descendant role in `role_id`'s inheritance subtree.
|
|
1089
|
+
#
|
|
1090
|
+
# Walks DOWN the inheritance tree via `$graphLookup` against
|
|
1091
|
+
# `_Join:roles:_Role` (parent → children → grandchildren), then
|
|
1092
|
+
# joins to `_Join:users:_Role` to pluck member ids, and finally
|
|
1093
|
+
# filters out tombstoned `_User` rows so the fast path matches
|
|
1094
|
+
# the soft-delete semantics the Parse-Server-backed path gets for
|
|
1095
|
+
# free via REST CLP enforcement.
|
|
1096
|
+
#
|
|
1097
|
+
# When called with a scoped `as:` argument (not master mode),
|
|
1098
|
+
# the `_User` `$lookup` sub-pipeline is augmented with an
|
|
1099
|
+
# `_rperm` `$match` so the joined `_User` rows are filtered to
|
|
1100
|
+
# ones the scope can read. Without this, the join leaks
|
|
1101
|
+
# `_User._id` regardless of caller authorization.
|
|
1102
|
+
#
|
|
1103
|
+
# Same authorization contract, return-value contract, and
|
|
1104
|
+
# error-policy as {role_names_for_user}.
|
|
1105
|
+
#
|
|
1106
|
+
# @param role_id [String] a Parse `_Role.objectId`.
|
|
1107
|
+
# @param max_depth [Integer] BFS depth bound.
|
|
1108
|
+
# @param master [Boolean] when `true`, bypass `_Role` CLP and the
|
|
1109
|
+
# `_User` `_rperm` filter on the join. Mutually exclusive with
|
|
1110
|
+
# `as:`.
|
|
1111
|
+
# @param as [Parse::User, Parse::Pointer, nil] caller-scope user.
|
|
1112
|
+
# When provided, the scope is resolved via
|
|
1113
|
+
# {Parse::ACLScope.resolve!}, the resulting permission set is
|
|
1114
|
+
# checked against `_Role` CLP, and the resolved `_rperm`
|
|
1115
|
+
# allow-set is injected into the `_User` join sub-pipeline.
|
|
1116
|
+
# @return [Set<String>, nil] resolved `_User.objectId`s, or nil
|
|
1117
|
+
# when the fast path is unavailable.
|
|
1118
|
+
# @raise [ArgumentError] when neither `master:` nor `as:` is
|
|
1119
|
+
# supplied, or when both are supplied.
|
|
1120
|
+
# @raise [Parse::CLPScope::Denied] when `as:` is supplied and the
|
|
1121
|
+
# scope cannot `find` on `_Role`.
|
|
1122
|
+
def users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil)
|
|
1123
|
+
resolution = authorize_role_graph_call!(
|
|
1124
|
+
:users_in_role_subtree, master: master, as: as,
|
|
1125
|
+
)
|
|
1126
|
+
validate_role_graph_id!(role_id, "role_id")
|
|
1127
|
+
depth = validate_role_graph_depth!(max_depth)
|
|
1128
|
+
return Set.new if depth <= 0
|
|
1129
|
+
return nil unless available?
|
|
1130
|
+
|
|
1131
|
+
graph_depth = depth - 1
|
|
1132
|
+
# Caller-scope path injects the resolved _rperm allow-set into
|
|
1133
|
+
# the _User sub-pipeline so the join honors row-level ACL.
|
|
1134
|
+
# Master mode leaves the sub-pipeline unscoped — the explicit
|
|
1135
|
+
# `master: true` is the operator's intent.
|
|
1136
|
+
rperm_allow = nil
|
|
1137
|
+
unless resolution.nil? || resolution.master?
|
|
1138
|
+
rperm_allow = resolution.permission_strings
|
|
1139
|
+
end
|
|
1140
|
+
pipeline = build_role_subtree_users_pipeline(
|
|
1141
|
+
role_id, graph_depth, rperm_allow: rperm_allow,
|
|
1142
|
+
)
|
|
1143
|
+
Parse::PipelineSecurity.validate_filter!(
|
|
1144
|
+
pipeline, allow_internal_fields: true,
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
result_set = nil
|
|
1148
|
+
ActiveSupport::Notifications.instrument(
|
|
1149
|
+
"parse.mongodb.role_graph",
|
|
1150
|
+
direction: :reverse, target_id: role_id, depth: depth,
|
|
1151
|
+
) do |payload|
|
|
1152
|
+
docs = collection("_Join:roles:_Role").aggregate(
|
|
1153
|
+
pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS,
|
|
1154
|
+
).to_a
|
|
1155
|
+
ids = Array(docs.first && docs.first["user_ids"])
|
|
1156
|
+
result_set = Set.new(
|
|
1157
|
+
ids.reject { |i| i.nil? || i.to_s.empty? }.map(&:to_s),
|
|
1158
|
+
)
|
|
1159
|
+
payload[:result_count] = result_set.size
|
|
1160
|
+
end
|
|
1161
|
+
result_set
|
|
1162
|
+
rescue NotEnabled, GemNotAvailable
|
|
1163
|
+
nil
|
|
1164
|
+
rescue StandardError => e
|
|
1165
|
+
if defined?(::Mongo::Error::OperationFailure) &&
|
|
1166
|
+
e.is_a?(::Mongo::Error::OperationFailure)
|
|
1167
|
+
raise_if_timeout!(e, "_Join:roles:_Role", ROLE_GRAPH_MAX_TIME_MS)
|
|
1168
|
+
end
|
|
1169
|
+
raise
|
|
1170
|
+
end
|
|
1171
|
+
|
|
1172
|
+
# @!visibility private
|
|
1173
|
+
# True when the SDK's default client has a non-empty master key in
|
|
1174
|
+
# its boot configuration. This is a **process-level configuration
|
|
1175
|
+
# check**, NOT a per-call authorization check — it tells you that
|
|
1176
|
+
# the SDK was constructed with a master key, not that the caller
|
|
1177
|
+
# presented one. The two states are very different: a scoped agent
|
|
1178
|
+
# (acl_user / acl_role / session_token) running in a process whose
|
|
1179
|
+
# default client was booted with a master key will still see this
|
|
1180
|
+
# method return `true`, even though the caller has no master-key
|
|
1181
|
+
# authority.
|
|
1182
|
+
#
|
|
1183
|
+
# Retained for backwards-compat callers that introspect SDK boot
|
|
1184
|
+
# state. **Never use as an authorization gate**; use
|
|
1185
|
+
# {.authorize_role_graph_call!} (or the equivalent path-specific
|
|
1186
|
+
# check) for per-call authorization.
|
|
1187
|
+
def master_key_available?
|
|
1188
|
+
return false unless defined?(Parse) && Parse.respond_to?(:client)
|
|
1189
|
+
c = begin
|
|
1190
|
+
Parse.client
|
|
1191
|
+
rescue StandardError
|
|
1192
|
+
nil
|
|
1193
|
+
end
|
|
1194
|
+
return false if c.nil?
|
|
1195
|
+
key = c.respond_to?(:master_key) ? c.master_key : nil
|
|
1196
|
+
key.is_a?(String) && !key.empty?
|
|
1197
|
+
end
|
|
1198
|
+
|
|
1199
|
+
# @!visibility private
|
|
1200
|
+
# Backwards-compat alias for {.master_key_available?}. Prefer the
|
|
1201
|
+
# new name in new code — `available?` reflects the actual meaning
|
|
1202
|
+
# ("the SDK has a master key it could use") more clearly than
|
|
1203
|
+
# `configured?` (which sounded like "the caller has master-key
|
|
1204
|
+
# authority"). Same warning applies: never an authorization gate.
|
|
1205
|
+
def master_key_configured?
|
|
1206
|
+
master_key_available?
|
|
1207
|
+
end
|
|
1208
|
+
|
|
1209
|
+
# @!visibility private
|
|
1210
|
+
# Enforce per-call authorization for the role-graph helpers.
|
|
1211
|
+
# Caller must supply either `master: true` OR an explicit
|
|
1212
|
+
# `as: <User|Pointer>` scope; passing both is rejected. When `as:`
|
|
1213
|
+
# is supplied, the scope is resolved through
|
|
1214
|
+
# {Parse::ACLScope.resolve!} and the resulting permission set is
|
|
1215
|
+
# checked against `_Role` CLP via {Parse::CLPScope.permits?}. CLP
|
|
1216
|
+
# denial raises {Parse::CLPScope::Denied}. Master mode bypasses
|
|
1217
|
+
# the CLP check (analytics jobs, admin tooling).
|
|
1218
|
+
#
|
|
1219
|
+
# @param method_name [Symbol] caller's method name, for error msgs.
|
|
1220
|
+
# @param master [Boolean] explicit master-mode opt-in.
|
|
1221
|
+
# @param as [Parse::User, Parse::Pointer, nil] caller-scope user.
|
|
1222
|
+
# @return [Parse::ACLScope::Resolution] the resolved auth state.
|
|
1223
|
+
# `resolution.master?` is true in master mode; otherwise the
|
|
1224
|
+
# resolution carries the user's permission strings.
|
|
1225
|
+
# @raise [ArgumentError] when neither (or both) of `master:`/`as:`
|
|
1226
|
+
# are provided.
|
|
1227
|
+
# @raise [Parse::CLPScope::Denied] when the resolved scope cannot
|
|
1228
|
+
# `find` on `_Role`.
|
|
1229
|
+
def authorize_role_graph_call!(method_name, master:, as:)
|
|
1230
|
+
if master == true && !as.nil?
|
|
1231
|
+
raise ArgumentError,
|
|
1232
|
+
"Parse::MongoDB.#{method_name}: pass exactly one of " \
|
|
1233
|
+
"`master: true` or `as: <Parse::User|Parse::Pointer>`. " \
|
|
1234
|
+
"They are mutually exclusive."
|
|
1235
|
+
end
|
|
1236
|
+
|
|
1237
|
+
if master == true
|
|
1238
|
+
return Parse::ACLScope::Resolution.new(
|
|
1239
|
+
mode: :master, permission_strings: nil, user_id: nil, session: nil,
|
|
1240
|
+
)
|
|
1241
|
+
end
|
|
1242
|
+
|
|
1243
|
+
if as.nil?
|
|
1244
|
+
raise ArgumentError,
|
|
1245
|
+
"Parse::MongoDB.#{method_name}: refusing to enumerate the " \
|
|
1246
|
+
"role graph without an explicit authorization scope. Pass " \
|
|
1247
|
+
"`master: true` for admin/analytics use, OR `as: current_user` " \
|
|
1248
|
+
"to run under the caller's scope (subject to `_Role` CLP)."
|
|
1249
|
+
end
|
|
1250
|
+
|
|
1251
|
+
resolution = Parse::ACLScope.resolve!({ acl_user: as }, method_name: method_name)
|
|
1252
|
+
unless resolution.master?
|
|
1253
|
+
perms = resolution.permission_strings
|
|
1254
|
+
unless Parse::CLPScope.permits?(Parse::Model::CLASS_ROLE, :find, perms)
|
|
1255
|
+
raise Parse::CLPScope::Denied.new(
|
|
1256
|
+
Parse::Model::CLASS_ROLE, :find,
|
|
1257
|
+
"Parse::MongoDB.#{method_name}: scope cannot `find` on " \
|
|
1258
|
+
"#{Parse::Model::CLASS_ROLE.inspect} under the current CLP. " \
|
|
1259
|
+
"Pass `master: true` to bypass, or grant the scope `find` " \
|
|
1260
|
+
"permission on _Role.",
|
|
1261
|
+
)
|
|
1262
|
+
end
|
|
1263
|
+
end
|
|
1264
|
+
resolution
|
|
1265
|
+
end
|
|
1266
|
+
|
|
1267
|
+
# @!visibility private
|
|
1268
|
+
# Format-only validation: confirms `id` is a non-empty String of
|
|
1269
|
+
# up to 64 chars matching {ROLE_GRAPH_ID_RE}. Does **not** check
|
|
1270
|
+
# that the id exists in `_User` / `_Role` — that lookup would
|
|
1271
|
+
# require a second round-trip and would itself be subject to
|
|
1272
|
+
# authorization. The authorization contract enforced via
|
|
1273
|
+
# {.authorize_role_graph_call!} is the primary defense; this
|
|
1274
|
+
# validator is defense-in-depth against control-char injection
|
|
1275
|
+
# and oversize-string DoS in the `$match` predicate.
|
|
1276
|
+
def validate_role_graph_id!(id, name)
|
|
1277
|
+
unless id.is_a?(String) && ROLE_GRAPH_ID_RE.match?(id)
|
|
1278
|
+
raise ArgumentError,
|
|
1279
|
+
"Parse::MongoDB role-graph helpers require #{name} to match #{ROLE_GRAPH_ID_RE.inspect}; got #{id.inspect}"
|
|
1280
|
+
end
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1283
|
+
# @!visibility private
|
|
1284
|
+
def validate_role_graph_depth!(max_depth)
|
|
1285
|
+
unless max_depth.is_a?(Integer) && max_depth <= ROLE_GRAPH_MAX_DEPTH
|
|
1286
|
+
raise ArgumentError,
|
|
1287
|
+
"Parse::MongoDB role-graph helpers require max_depth to be an Integer no greater than #{ROLE_GRAPH_MAX_DEPTH}; got #{max_depth.inspect}"
|
|
1288
|
+
end
|
|
1289
|
+
max_depth
|
|
1290
|
+
end
|
|
1291
|
+
|
|
1292
|
+
# @!visibility private
|
|
1293
|
+
def build_user_role_names_pipeline(user_id, graph_depth)
|
|
1294
|
+
pipeline = [
|
|
1295
|
+
{ "$match" => { "relatedId" => user_id } },
|
|
1296
|
+
{ "$graphLookup" => {
|
|
1297
|
+
"from" => "_Join:roles:_Role",
|
|
1298
|
+
"startWith" => "$owningId",
|
|
1299
|
+
"connectFromField" => "owningId",
|
|
1300
|
+
"connectToField" => "relatedId",
|
|
1301
|
+
"as" => "parent_chain",
|
|
1302
|
+
"maxDepth" => graph_depth,
|
|
1303
|
+
} },
|
|
1304
|
+
{ "$project" => {
|
|
1305
|
+
"_id" => 0,
|
|
1306
|
+
"role_ids" => {
|
|
1307
|
+
"$setUnion" => [["$owningId"], "$parent_chain.owningId"],
|
|
1308
|
+
},
|
|
1309
|
+
} },
|
|
1310
|
+
{ "$unwind" => "$role_ids" },
|
|
1311
|
+
{ "$group" => { "_id" => nil, "ids" => { "$addToSet" => "$role_ids" } } },
|
|
1312
|
+
{ "$lookup" => {
|
|
1313
|
+
"from" => "_Role",
|
|
1314
|
+
"localField" => "ids",
|
|
1315
|
+
"foreignField" => "_id",
|
|
1316
|
+
"as" => "roles",
|
|
1317
|
+
} },
|
|
1318
|
+
{ "$project" => { "_id" => 0, "names" => "$roles.name" } },
|
|
1319
|
+
]
|
|
1320
|
+
# Defense-in-depth: hardcoded-shape assertions catch any future
|
|
1321
|
+
# regression that interpolates a caller value into a
|
|
1322
|
+
# graph-traversal field. The validator can't tell the difference
|
|
1323
|
+
# between an SDK-built constant and a tainted value once the
|
|
1324
|
+
# pipeline is assembled, so we check here at the boundary.
|
|
1325
|
+
assert_user_role_names_pipeline_shape!(pipeline, user_id, graph_depth)
|
|
1326
|
+
pipeline
|
|
1327
|
+
end
|
|
1328
|
+
|
|
1329
|
+
# @!visibility private
|
|
1330
|
+
def build_role_subtree_users_pipeline(role_id, graph_depth, rperm_allow: nil)
|
|
1331
|
+
# `_User` sub-pipeline: by default filter only tombstones; when
|
|
1332
|
+
# a scoped caller is in effect, also filter on _rperm so the
|
|
1333
|
+
# join honors row-level ACL. Master mode passes `rperm_allow: nil`
|
|
1334
|
+
# and gets the unscoped form (the explicit master opt-in is the
|
|
1335
|
+
# operator's intent).
|
|
1336
|
+
user_match = {
|
|
1337
|
+
"$expr" => { "$in" => ["$_id", "$$ids"] },
|
|
1338
|
+
"_tombstone" => { "$exists" => false },
|
|
1339
|
+
}
|
|
1340
|
+
if rperm_allow.is_a?(Array) && rperm_allow.any?
|
|
1341
|
+
user_match.merge!(Parse::ACL.read_predicate(rperm_allow))
|
|
1342
|
+
end
|
|
1343
|
+
|
|
1344
|
+
pipeline = [
|
|
1345
|
+
{ "$match" => { "owningId" => role_id } },
|
|
1346
|
+
{ "$graphLookup" => {
|
|
1347
|
+
"from" => "_Join:roles:_Role",
|
|
1348
|
+
"startWith" => "$relatedId",
|
|
1349
|
+
"connectFromField" => "relatedId",
|
|
1350
|
+
"connectToField" => "owningId",
|
|
1351
|
+
"as" => "descendant_chain",
|
|
1352
|
+
"maxDepth" => graph_depth,
|
|
1353
|
+
} },
|
|
1354
|
+
{ "$project" => {
|
|
1355
|
+
"_id" => 0,
|
|
1356
|
+
"role_ids" => {
|
|
1357
|
+
"$setUnion" => [["$relatedId"], "$descendant_chain.relatedId"],
|
|
1358
|
+
},
|
|
1359
|
+
} },
|
|
1360
|
+
{ "$unwind" => "$role_ids" },
|
|
1361
|
+
{ "$group" => { "_id" => nil, "ids" => { "$addToSet" => "$role_ids" } } },
|
|
1362
|
+
{ "$project" => {
|
|
1363
|
+
"_id" => 0,
|
|
1364
|
+
"ids" => { "$setUnion" => ["$ids", [role_id]] },
|
|
1365
|
+
} },
|
|
1366
|
+
{ "$lookup" => {
|
|
1367
|
+
"from" => "_Join:users:_Role",
|
|
1368
|
+
"localField" => "ids",
|
|
1369
|
+
"foreignField" => "owningId",
|
|
1370
|
+
"as" => "memberships",
|
|
1371
|
+
} },
|
|
1372
|
+
{ "$project" => {
|
|
1373
|
+
"_id" => 0,
|
|
1374
|
+
"user_id_candidates" => "$memberships.relatedId",
|
|
1375
|
+
} },
|
|
1376
|
+
# Filter tombstoned _User rows AND project only `_id` server-side
|
|
1377
|
+
# via pipeline-form $lookup (3.6+). Without this, a role with N
|
|
1378
|
+
# members pulls N full _User docs (hashed_password, session
|
|
1379
|
+
# tokens, _auth_data_*) over the wire just to read `_id`. That
|
|
1380
|
+
# shape DoSes on a large role; the pipeline-form keeps the wire
|
|
1381
|
+
# payload bounded to N `_id` strings.
|
|
1382
|
+
#
|
|
1383
|
+
# When `rperm_allow` is non-empty (caller-scope path), the
|
|
1384
|
+
# `_rperm` match is folded into the sub-pipeline filter so the
|
|
1385
|
+
# join honors row-level ACL.
|
|
1386
|
+
{ "$lookup" => {
|
|
1387
|
+
"from" => "_User",
|
|
1388
|
+
"let" => { "ids" => "$user_id_candidates" },
|
|
1389
|
+
"pipeline" => [
|
|
1390
|
+
{ "$match" => user_match },
|
|
1391
|
+
{ "$project" => { "_id" => 1 } },
|
|
1392
|
+
],
|
|
1393
|
+
"as" => "active_users",
|
|
1394
|
+
} },
|
|
1395
|
+
{ "$project" => {
|
|
1396
|
+
"_id" => 0,
|
|
1397
|
+
"user_ids" => "$active_users._id",
|
|
1398
|
+
} },
|
|
1399
|
+
]
|
|
1400
|
+
# Defense-in-depth shape assertions (see comment in
|
|
1401
|
+
# build_user_role_names_pipeline for rationale).
|
|
1402
|
+
assert_role_subtree_users_pipeline_shape!(pipeline, role_id, graph_depth)
|
|
1403
|
+
pipeline
|
|
1404
|
+
end
|
|
1405
|
+
|
|
1406
|
+
# @!visibility private
|
|
1407
|
+
# Hardcoded-shape assertion for build_user_role_names_pipeline.
|
|
1408
|
+
# Designed to fail loudly if a future change interpolates a caller
|
|
1409
|
+
# value into `connectFromField` / `connectToField` / `from` /
|
|
1410
|
+
# `startWith`. These fields drive the BFS direction in MongoDB; a
|
|
1411
|
+
# caller value here would be a query-injection primitive.
|
|
1412
|
+
def assert_user_role_names_pipeline_shape!(pipeline, user_id, graph_depth)
|
|
1413
|
+
raise "role-graph pipeline shape regression: $match.relatedId must equal user_id" \
|
|
1414
|
+
unless pipeline[0].is_a?(Hash) && pipeline[0]["$match"].is_a?(Hash) &&
|
|
1415
|
+
pipeline[0]["$match"]["relatedId"] == user_id
|
|
1416
|
+
gl = pipeline[1] && pipeline[1]["$graphLookup"]
|
|
1417
|
+
raise "role-graph pipeline shape regression: missing $graphLookup stage" \
|
|
1418
|
+
unless gl.is_a?(Hash)
|
|
1419
|
+
raise "role-graph pipeline shape regression: $graphLookup.from must be a hardcoded String" \
|
|
1420
|
+
unless gl["from"] == "_Join:roles:_Role"
|
|
1421
|
+
raise "role-graph pipeline shape regression: $graphLookup.connectFromField must be hardcoded" \
|
|
1422
|
+
unless gl["connectFromField"] == "owningId"
|
|
1423
|
+
raise "role-graph pipeline shape regression: $graphLookup.connectToField must be hardcoded" \
|
|
1424
|
+
unless gl["connectToField"] == "relatedId"
|
|
1425
|
+
raise "role-graph pipeline shape regression: $graphLookup.startWith must be hardcoded" \
|
|
1426
|
+
unless gl["startWith"] == "$owningId"
|
|
1427
|
+
raise "role-graph pipeline shape regression: $graphLookup.maxDepth must be Integer" \
|
|
1428
|
+
unless gl["maxDepth"].is_a?(Integer) && gl["maxDepth"] == graph_depth
|
|
1429
|
+
end
|
|
1430
|
+
|
|
1431
|
+
# @!visibility private
|
|
1432
|
+
# Hardcoded-shape assertion for build_role_subtree_users_pipeline.
|
|
1433
|
+
def assert_role_subtree_users_pipeline_shape!(pipeline, role_id, graph_depth)
|
|
1434
|
+
raise "role-graph pipeline shape regression: $match.owningId must equal role_id" \
|
|
1435
|
+
unless pipeline[0].is_a?(Hash) && pipeline[0]["$match"].is_a?(Hash) &&
|
|
1436
|
+
pipeline[0]["$match"]["owningId"] == role_id
|
|
1437
|
+
gl = pipeline[1] && pipeline[1]["$graphLookup"]
|
|
1438
|
+
raise "role-graph pipeline shape regression: missing $graphLookup stage" \
|
|
1439
|
+
unless gl.is_a?(Hash)
|
|
1440
|
+
raise "role-graph pipeline shape regression: $graphLookup.from must be a hardcoded String" \
|
|
1441
|
+
unless gl["from"] == "_Join:roles:_Role"
|
|
1442
|
+
raise "role-graph pipeline shape regression: $graphLookup.connectFromField must be hardcoded" \
|
|
1443
|
+
unless gl["connectFromField"] == "relatedId"
|
|
1444
|
+
raise "role-graph pipeline shape regression: $graphLookup.connectToField must be hardcoded" \
|
|
1445
|
+
unless gl["connectToField"] == "owningId"
|
|
1446
|
+
raise "role-graph pipeline shape regression: $graphLookup.startWith must be hardcoded" \
|
|
1447
|
+
unless gl["startWith"] == "$relatedId"
|
|
1448
|
+
raise "role-graph pipeline shape regression: $graphLookup.maxDepth must be Integer" \
|
|
1449
|
+
unless gl["maxDepth"].is_a?(Integer) && gl["maxDepth"] == graph_depth
|
|
1450
|
+
# Final _User $lookup carries the hardcoded foreign collection.
|
|
1451
|
+
user_lookup = pipeline.find { |s| s.dig("$lookup", "from") == "_User" }
|
|
1452
|
+
raise "role-graph pipeline shape regression: missing _User $lookup stage" \
|
|
1453
|
+
unless user_lookup.is_a?(Hash)
|
|
1454
|
+
end
|
|
1455
|
+
|
|
1456
|
+
# Execute an aggregation pipeline directly on MongoDB
|
|
1457
|
+
# @param collection_name [String] the collection name
|
|
1458
|
+
# @param pipeline [Array<Hash>] the aggregation pipeline stages
|
|
1459
|
+
# @param max_time_ms [Integer, nil] optional server-side time limit in milliseconds.
|
|
1460
|
+
# When provided, MongoDB will cancel the query if it exceeds this budget and
|
|
1461
|
+
# the driver error is translated to {Parse::MongoDB::ExecutionTimeout}.
|
|
1462
|
+
# Pass +nil+ (the default) for no cap.
|
|
1463
|
+
# @return [Array<Hash>] the raw results from MongoDB
|
|
1464
|
+
# @param rewrite_lookups [Boolean, nil] when true (default `nil` --
|
|
1465
|
+
# reads `Parse.rewrite_lookups`), auto-rewrite LLM-style $lookup
|
|
1466
|
+
# stages against logical class names into the Parse-on-Mongo
|
|
1467
|
+
# column form when the foreign class declares `parse_reference`.
|
|
1468
|
+
# @param allow_internal_fields [Boolean] when true, skip the
|
|
1469
|
+
# internal-fields denylist check (e.g. for SDK-generated ACL
|
|
1470
|
+
# filters produced by {Parse::Query#readable_by_role} and friends
|
|
1471
|
+
# that legitimately reference +_rperm+/+_wperm+). The
|
|
1472
|
+
# DENIED_OPERATORS walk, forensic-operator-in-+$expr+ check, and
|
|
1473
|
+
# internal-field +$+-reference string check all still run.
|
|
1474
|
+
# Passed +true+ only from the SDK direct-execution sites that
|
|
1475
|
+
# build their pipeline entirely from {Parse::Query#compile_where}:
|
|
1476
|
+
# +Parse::Query#results_direct+, +#first_direct+ (via
|
|
1477
|
+
# +results_direct+), +#count_direct+, +#distinct_direct+,
|
|
1478
|
+
# +#atlas_search+ builder-block, and the two +#group_by_*+ direct
|
|
1479
|
+
# paths. The Agent MCP tool path and +Aggregation#execute_direct!+
|
|
1480
|
+
# keep the default +false+ so attacker-controlled or user-supplied
|
|
1481
|
+
# aggregate stages cannot reach internal columns.
|
|
1482
|
+
# @param session_token [String, nil] when provided, the SDK
|
|
1483
|
+
# resolves the token to the requesting user + role membership
|
|
1484
|
+
# (via {Parse::AtlasSearch::Session}) and prepends an
|
|
1485
|
+
# `_rperm` `$match` stage to the pipeline so the result set
|
|
1486
|
+
# simulates Parse Server's row-level ACL enforcement. This
|
|
1487
|
+
# path is the only ACL boundary on a mongo-direct call — the
|
|
1488
|
+
# underlying Mongo connection is admin-credentialed at
|
|
1489
|
+
# `Parse::MongoDB.configure` time, so the SDK *is* the
|
|
1490
|
+
# enforcement layer. Mutually exclusive with `master:`.
|
|
1491
|
+
# @param master [Boolean, nil] pass `true` to explicitly bypass
|
|
1492
|
+
# the SDK's row-ACL injection (analytics jobs, admin tooling
|
|
1493
|
+
# that legitimately needs to read across users). Mutually
|
|
1494
|
+
# exclusive with `session_token:`.
|
|
1495
|
+
# @raise [Parse::MongoDB::DeniedOperator] if the pipeline contains
|
|
1496
|
+
# a server-side JS or data-mutating operator at any depth.
|
|
1497
|
+
# @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds max_time_ms
|
|
1498
|
+
# @raise [Parse::ACLScope::ACLRequired] when neither
|
|
1499
|
+
# `session_token:` nor `master: true` is supplied and
|
|
1500
|
+
# {Parse::ACLScope.require_session_token} is enabled.
|
|
1501
|
+
def aggregate(collection_name, pipeline, max_time_ms: nil, rewrite_lookups: nil, allow_internal_fields: false, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil)
|
|
1502
|
+
# Resolve auth kwargs into a Parse::ACLScope::Resolution. The
|
|
1503
|
+
# call MUTATES the temporary kwargs hash (popping the auth
|
|
1504
|
+
# entries) before the resolution; we package them into a hash
|
|
1505
|
+
# here only so the shared helper can stay path-agnostic. The
|
|
1506
|
+
# hash is local and discarded after the call.
|
|
1507
|
+
auth_kwargs = {
|
|
1508
|
+
session_token: session_token,
|
|
1509
|
+
master: master,
|
|
1510
|
+
acl_user: acl_user,
|
|
1511
|
+
acl_role: acl_role,
|
|
1512
|
+
}.compact
|
|
1513
|
+
resolution = Parse::ACLScope.resolve!(auth_kwargs, method_name: :aggregate)
|
|
1514
|
+
|
|
1515
|
+
# Validate BEFORE rewrite so the security denylist is applied to the
|
|
1516
|
+
# caller's original pipeline (which an attacker controls), not to
|
|
1517
|
+
# the gem-rewritten form (which it doesn't). Matches the ordering
|
|
1518
|
+
# used by Parse::Query#aggregate and Parse::Agent::Tools.aggregate.
|
|
1519
|
+
assert_no_denied_operators!(pipeline, allow_internal_fields: allow_internal_fields)
|
|
1520
|
+
|
|
1521
|
+
# Wave-3 TRACK-CLP-4: refuse any caller-supplied `$<field>`
|
|
1522
|
+
# reference that names a protectedField for the queried class
|
|
1523
|
+
# in the current scope. The post-fetch redact strips by NAME,
|
|
1524
|
+
# so a pipeline can launder a protected value through a
|
|
1525
|
+
# `$project: { renamed: "$ssn" }` (and similar) clauses and
|
|
1526
|
+
# bypass the strip silently. Catching the reference here at
|
|
1527
|
+
# parse-time refuses the join with `Parse::CLPScope::Denied`
|
|
1528
|
+
# so the bypass surfaces as an explicit error rather than a
|
|
1529
|
+
# quiet exfiltration. Master mode short-circuits inside the
|
|
1530
|
+
# scanner (no protected set on master).
|
|
1531
|
+
Parse::PipelineSecurity.refuse_protected_field_references!(
|
|
1532
|
+
pipeline, collection_name, resolution,
|
|
1533
|
+
)
|
|
1534
|
+
|
|
1535
|
+
pipeline = Parse::LookupRewriter.auto_rewrite(
|
|
1536
|
+
pipeline, class_name: collection_name, enabled: rewrite_lookups,
|
|
1537
|
+
)
|
|
1538
|
+
|
|
1539
|
+
# Three-layer ACL simulation on the mongo-direct path:
|
|
1540
|
+
#
|
|
1541
|
+
# 1. Top-level $match: filter the queried collection's rows by
|
|
1542
|
+
# the session's _rperm allow-set. Mirrors Parse Server's
|
|
1543
|
+
# REST find behavior.
|
|
1544
|
+
# 2. Pipeline rewriter: every $lookup / $unionWith / $graphLookup /
|
|
1545
|
+
# $facet sub-pipeline gets the same _rperm filter embedded
|
|
1546
|
+
# so joined rows from other collections are filtered at the
|
|
1547
|
+
# database. Without this, includes/joins would silently leak
|
|
1548
|
+
# rows the requesting session has no permission to read.
|
|
1549
|
+
# 3. Post-fetch redaction: walk the returned documents and
|
|
1550
|
+
# scrub any embedded sub-documents whose stored _rperm
|
|
1551
|
+
# doesn't match the perms set. Catches cases the rewriter
|
|
1552
|
+
# can't reach (e.g., :object columns embedding raw pointer
|
|
1553
|
+
# hashes, or caller-supplied $lookup stages that escaped
|
|
1554
|
+
# rewriting because of unusual shapes).
|
|
1555
|
+
#
|
|
1556
|
+
# The security validator already ran on the caller's original
|
|
1557
|
+
# pipeline above; the injected stages reference `_rperm` but
|
|
1558
|
+
# are SDK-generated (not attacker-controlled), so no
|
|
1559
|
+
# re-validation is needed before they're handed to MongoDB.
|
|
1560
|
+
if (acl_stage = Parse::ACLScope.match_stage_for(resolution))
|
|
1561
|
+
pipeline = [acl_stage] + pipeline
|
|
1562
|
+
end
|
|
1563
|
+
pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution)
|
|
1564
|
+
|
|
1565
|
+
# Class-Level Permissions boundary check. Parse Server's REST
|
|
1566
|
+
# aggregate endpoint runs master-key-only and does NOT enforce
|
|
1567
|
+
# CLP; the mongo-direct path bypasses Parse Server entirely so
|
|
1568
|
+
# the SDK is the only enforcement layer. Refuse the call when
|
|
1569
|
+
# the resolved scope can't `find` on the collection. Master-
|
|
1570
|
+
# key (resolution.master? / nil permission_strings) bypasses.
|
|
1571
|
+
perms_for_clp = resolution&.permission_strings
|
|
1572
|
+
unless resolution.nil? || resolution.master?
|
|
1573
|
+
unless Parse::CLPScope.permits?(collection_name, :find, perms_for_clp)
|
|
1574
|
+
raise Parse::CLPScope::Denied.new(
|
|
1575
|
+
collection_name, :find,
|
|
1576
|
+
"CLP refuses find on '#{collection_name}' for the current scope.",
|
|
1577
|
+
)
|
|
1578
|
+
end
|
|
1579
|
+
end
|
|
1580
|
+
|
|
1581
|
+
# Resolve the pointerFields constraint (if any) BEFORE running
|
|
1582
|
+
# the query — we apply the filter post-fetch but want to fail
|
|
1583
|
+
# loudly when the scope can't satisfy the constraint at all
|
|
1584
|
+
# (acl_role-only / public agents have no user_id to match).
|
|
1585
|
+
pointer_fields = nil
|
|
1586
|
+
unless resolution.nil? || resolution.master?
|
|
1587
|
+
pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find)
|
|
1588
|
+
if pointer_fields && resolution.user_id.nil?
|
|
1589
|
+
raise Parse::CLPScope::Denied.new(
|
|
1590
|
+
collection_name, :find,
|
|
1591
|
+
"CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \
|
|
1592
|
+
"but the current scope has no user_id.",
|
|
1593
|
+
)
|
|
1594
|
+
end
|
|
1595
|
+
end
|
|
1596
|
+
|
|
1597
|
+
agg_opts = {}
|
|
1598
|
+
agg_opts[:max_time_ms] = max_time_ms if max_time_ms
|
|
1599
|
+
coll = collection(collection_name)
|
|
1600
|
+
if (mode = normalize_read_preference(read_preference))
|
|
1601
|
+
coll = coll.with(read: { mode: mode })
|
|
1602
|
+
end
|
|
1603
|
+
results = coll.aggregate(pipeline, agg_opts).to_a
|
|
1604
|
+
Parse::ACLScope.redact_results!(results, resolution)
|
|
1605
|
+
|
|
1606
|
+
# Post-fetch pointerFields filter: drop rows where none of the
|
|
1607
|
+
# named pointer fields references the requesting user. Skipped
|
|
1608
|
+
# for master-key and when the CLP has no pointerFields entry.
|
|
1609
|
+
if pointer_fields
|
|
1610
|
+
results = Parse::CLPScope.filter_by_pointer_fields(
|
|
1611
|
+
results, pointer_fields, resolution.user_id,
|
|
1612
|
+
)
|
|
1613
|
+
end
|
|
1614
|
+
|
|
1615
|
+
# Protected fields stripping. Resolve the field set per the
|
|
1616
|
+
# session's claim composition and walk-delete from every
|
|
1617
|
+
# row + embedded sub-document. Top-level $project would also
|
|
1618
|
+
# work but doesn't reach inside `$lookup`-included sub-docs,
|
|
1619
|
+
# so the post-walker is the defense-in-depth layer.
|
|
1620
|
+
unless resolution.nil? || resolution.master?
|
|
1621
|
+
strip_set = Parse::CLPScope.protected_fields_for(
|
|
1622
|
+
collection_name, perms_for_clp,
|
|
1623
|
+
)
|
|
1624
|
+
Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any?
|
|
1625
|
+
end
|
|
1626
|
+
|
|
1627
|
+
results
|
|
1628
|
+
rescue => e
|
|
1629
|
+
raise_if_timeout!(e, collection_name, max_time_ms)
|
|
1630
|
+
raise
|
|
1631
|
+
end
|
|
1632
|
+
|
|
1633
|
+
# Execute a `$geoNear` aggregation against a collection, returning
|
|
1634
|
+
# documents sorted by proximity to `near` along with their computed
|
|
1635
|
+
# distance. `$geoNear` is the aggregation-pipeline analogue of
|
|
1636
|
+
# `$nearSphere`; the headline differences are that it emits the
|
|
1637
|
+
# distance value on each returned doc (`distance_field:`) and that
|
|
1638
|
+
# downstream pipeline stages can compose with the proximity sort.
|
|
1639
|
+
#
|
|
1640
|
+
# A `2dsphere` index on the queried geo field is **required**; the
|
|
1641
|
+
# operation errors loudly without one (no silent collection scan).
|
|
1642
|
+
# `$geoNear` must be the first stage in the pipeline — Parse::MongoDB
|
|
1643
|
+
# places it correctly. The Mongo default 100-document cap was removed
|
|
1644
|
+
# in recent server versions, so pass an explicit `limit:` whenever
|
|
1645
|
+
# the caller would otherwise drain the entire collection.
|
|
1646
|
+
#
|
|
1647
|
+
# @example
|
|
1648
|
+
# center = Parse::GeoPoint.new(32.7157, -117.1611)
|
|
1649
|
+
# Parse::MongoDB.geo_near("Place",
|
|
1650
|
+
# near: center,
|
|
1651
|
+
# max_distance: 5,
|
|
1652
|
+
# unit: :km,
|
|
1653
|
+
# query: { category: "Park" },
|
|
1654
|
+
# limit: 25,
|
|
1655
|
+
# )
|
|
1656
|
+
# # Each result document carries a `dist.calculated` field (meters).
|
|
1657
|
+
#
|
|
1658
|
+
# @param collection_name [String] the MongoDB collection name. Use
|
|
1659
|
+
# `klass.parse_class` when starting from a Parse::Object subclass.
|
|
1660
|
+
# @param near [Parse::GeoPoint, Hash, Array] the anchor point.
|
|
1661
|
+
# Accepts a {Parse::GeoPoint}, a GeoJSON `Point` Hash, or a
|
|
1662
|
+
# `[longitude, latitude]` Array. Modern Mongo (8.0+) strictly
|
|
1663
|
+
# validates GeoJSON-shaped input, so {Parse::GeoPoint} is preferred.
|
|
1664
|
+
# @param distance_field [String] output field name on each result
|
|
1665
|
+
# document for the computed distance. Dot notation is permitted
|
|
1666
|
+
# (e.g. `"dist.calculated"`). Defaults to `"distance"`.
|
|
1667
|
+
# @param max_distance [Numeric, nil] inclusive upper bound on
|
|
1668
|
+
# distance. With a 2dsphere index, the wire unit is **meters**;
|
|
1669
|
+
# pass `unit:` to convert from km or miles. With a legacy 2d
|
|
1670
|
+
# index the wire unit is radians (advanced; caller's burden).
|
|
1671
|
+
# @param min_distance [Numeric, nil] inclusive lower bound, same
|
|
1672
|
+
# unit semantics as `max_distance`.
|
|
1673
|
+
# @param unit [Symbol] one of `:meters` (default), `:km` /
|
|
1674
|
+
# `:kilometers`, `:miles`. Converts the user-supplied `max_distance`
|
|
1675
|
+
# and `min_distance` to meters before serializing.
|
|
1676
|
+
# @param spherical [Boolean] use spherical geometry. Defaults to
|
|
1677
|
+
# `true` — the conventional pairing with 2dsphere + GeoJSON. Set
|
|
1678
|
+
# to `false` only when querying a legacy planar 2d index.
|
|
1679
|
+
# @param query [Hash, nil] additional filter applied to candidate
|
|
1680
|
+
# documents. Cannot contain a `$near` predicate (Mongo rejects).
|
|
1681
|
+
# @param include_locs [String, nil] when set, the matched location
|
|
1682
|
+
# value is added to each result under this field name. Useful for
|
|
1683
|
+
# documents that may hold multiple geo fields.
|
|
1684
|
+
# @param key [String, nil] explicit geo field path. Required when
|
|
1685
|
+
# the collection has multiple geo indexes; otherwise Mongo picks
|
|
1686
|
+
# the unique 2d/2dsphere index automatically.
|
|
1687
|
+
# @param distance_multiplier [Numeric, nil] post-computation scalar
|
|
1688
|
+
# applied to every returned distance. The 2dsphere + meters path
|
|
1689
|
+
# typically does not need this; legacy 2d callers can pass an
|
|
1690
|
+
# Earth-radius constant to convert radians to km/miles.
|
|
1691
|
+
# @param limit [Integer, nil] when provided, appends a `$limit`
|
|
1692
|
+
# stage. The Mongo default 100-doc cap is no longer applied
|
|
1693
|
+
# automatically — set `limit:` (or pass `:limit => 0` to mean
|
|
1694
|
+
# "unbounded; I really mean it") to control the size.
|
|
1695
|
+
# @param additional_stages [Array<Hash>] extra pipeline stages to
|
|
1696
|
+
# append after `$geoNear` (and after `$limit` if any). Useful for
|
|
1697
|
+
# `$lookup` joins, `$project` field shaping, etc. Each stage
|
|
1698
|
+
# passes through the standard security validation.
|
|
1699
|
+
# @param max_time_ms [Integer, nil] server-side time limit; same
|
|
1700
|
+
# semantics as {.aggregate}.
|
|
1701
|
+
# @return [Array<Hash>] documents enriched with `distance_field`
|
|
1702
|
+
# (and `include_locs` when requested), in nearest-first order.
|
|
1703
|
+
# @raise [ArgumentError] when `near` is not a recognized point form
|
|
1704
|
+
# or when `unit` is unknown.
|
|
1705
|
+
# @raise [Parse::MongoDB::DeniedOperator] if `query:` or
|
|
1706
|
+
# `additional_stages:` contain a denied operator.
|
|
1707
|
+
# @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds
|
|
1708
|
+
# `max_time_ms`.
|
|
1709
|
+
def geo_near(collection_name,
|
|
1710
|
+
near:,
|
|
1711
|
+
distance_field: "distance",
|
|
1712
|
+
max_distance: nil,
|
|
1713
|
+
min_distance: nil,
|
|
1714
|
+
unit: :meters,
|
|
1715
|
+
spherical: true,
|
|
1716
|
+
query: nil,
|
|
1717
|
+
include_locs: nil,
|
|
1718
|
+
key: nil,
|
|
1719
|
+
distance_multiplier: nil,
|
|
1720
|
+
limit: nil,
|
|
1721
|
+
additional_stages: [],
|
|
1722
|
+
max_time_ms: nil,
|
|
1723
|
+
session_token: nil,
|
|
1724
|
+
master: nil,
|
|
1725
|
+
acl_user: nil,
|
|
1726
|
+
acl_role: nil,
|
|
1727
|
+
read_preference: nil)
|
|
1728
|
+
stage = { :$geoNear => {
|
|
1729
|
+
near: geojson_point_for(near),
|
|
1730
|
+
distanceField: distance_field.to_s,
|
|
1731
|
+
spherical: spherical ? true : false,
|
|
1732
|
+
} }
|
|
1733
|
+
|
|
1734
|
+
max_meters = convert_distance_to_meters(max_distance, unit) if max_distance
|
|
1735
|
+
min_meters = convert_distance_to_meters(min_distance, unit) if min_distance
|
|
1736
|
+
stage[:$geoNear][:maxDistance] = max_meters if max_meters
|
|
1737
|
+
stage[:$geoNear][:minDistance] = min_meters if min_meters
|
|
1738
|
+
stage[:$geoNear][:query] = query if query.is_a?(Hash) && !query.empty?
|
|
1739
|
+
stage[:$geoNear][:includeLocs] = include_locs.to_s if include_locs
|
|
1740
|
+
stage[:$geoNear][:key] = key.to_s if key
|
|
1741
|
+
stage[:$geoNear][:distanceMultiplier] = distance_multiplier if distance_multiplier
|
|
1742
|
+
|
|
1743
|
+
pipeline = [stage]
|
|
1744
|
+
pipeline << { :$limit => limit } if limit && limit > 0
|
|
1745
|
+
pipeline.concat(Array(additional_stages))
|
|
1746
|
+
|
|
1747
|
+
aggregate(collection_name, pipeline,
|
|
1748
|
+
max_time_ms: max_time_ms,
|
|
1749
|
+
session_token: session_token,
|
|
1750
|
+
master: master,
|
|
1751
|
+
acl_user: acl_user,
|
|
1752
|
+
acl_role: acl_role,
|
|
1753
|
+
read_preference: read_preference)
|
|
1754
|
+
end
|
|
1755
|
+
|
|
1756
|
+
# Execute a find query directly on MongoDB
|
|
1757
|
+
# @param collection_name [String] the collection name
|
|
1758
|
+
# @param filter [Hash] the query filter
|
|
1759
|
+
# @param options [Hash] additional options (limit, skip, sort, projection, max_time_ms).
|
|
1760
|
+
# When :limit is omitted, DEFAULT_FIND_LIMIT is applied before the
|
|
1761
|
+
# cursor is materialized and a warning is emitted if the cap is hit.
|
|
1762
|
+
# Pass `limit: 0` to explicitly request unbounded behavior.
|
|
1763
|
+
# When :max_time_ms is provided, MongoDB will cancel the query if it
|
|
1764
|
+
# exceeds the budget; the driver error is translated to
|
|
1765
|
+
# {Parse::MongoDB::ExecutionTimeout}.
|
|
1766
|
+
# @return [Array<Hash>] the raw results from MongoDB
|
|
1767
|
+
# @raise [Parse::MongoDB::DeniedOperator] if the filter contains
|
|
1768
|
+
# $where, $function, or $accumulator at any depth.
|
|
1769
|
+
# @raise [Parse::MongoDB::ExecutionTimeout] if the query exceeds max_time_ms
|
|
1770
|
+
def find(collection_name, filter = {}, **options)
|
|
1771
|
+
allow_internal_fields = options.delete(:allow_internal_fields) || false
|
|
1772
|
+
assert_no_denied_operators!(filter, allow_internal_fields: allow_internal_fields)
|
|
1773
|
+
max_time_ms = options.delete(:max_time_ms)
|
|
1774
|
+
cursor = collection(collection_name).find(filter)
|
|
1775
|
+
explicit_limit = options.key?(:limit)
|
|
1776
|
+
applied_default_limit = false
|
|
1777
|
+
|
|
1778
|
+
if explicit_limit
|
|
1779
|
+
cursor = cursor.limit(options[:limit]) if options[:limit] > 0
|
|
1780
|
+
else
|
|
1781
|
+
# Apply the hard default BEFORE to_a so we never materialize an
|
|
1782
|
+
# unbounded result set. Fetch one extra row so we can detect when
|
|
1783
|
+
# callers hit the cap and warn them.
|
|
1784
|
+
cursor = cursor.limit(DEFAULT_FIND_LIMIT + 1)
|
|
1785
|
+
applied_default_limit = true
|
|
1786
|
+
end
|
|
1787
|
+
|
|
1788
|
+
cursor = cursor.skip(options[:skip]) if options[:skip]
|
|
1789
|
+
cursor = cursor.sort(options[:sort]) if options[:sort]
|
|
1790
|
+
cursor = cursor.projection(options[:projection]) if options[:projection]
|
|
1791
|
+
cursor = cursor.max_time_ms(max_time_ms) if max_time_ms
|
|
1792
|
+
results = cursor.to_a
|
|
1793
|
+
|
|
1794
|
+
if applied_default_limit && results.size > DEFAULT_FIND_LIMIT
|
|
1795
|
+
# Trim the sentinel row and warn — the caller asked for everything
|
|
1796
|
+
# but the result set is larger than the safety cap.
|
|
1797
|
+
results = results.first(DEFAULT_FIND_LIMIT)
|
|
1798
|
+
warn "[Parse::MongoDB.find] on '#{collection_name}' truncated to " \
|
|
1799
|
+
"#{DEFAULT_FIND_LIMIT} rows (no :limit specified). Pass an " \
|
|
1800
|
+
"explicit :limit to control the size, or :limit => 0 for " \
|
|
1801
|
+
"unbounded behavior."
|
|
1802
|
+
end
|
|
1803
|
+
|
|
1804
|
+
results
|
|
1805
|
+
rescue => e
|
|
1806
|
+
raise_if_timeout!(e, collection_name, max_time_ms)
|
|
1807
|
+
raise
|
|
1808
|
+
end
|
|
1809
|
+
|
|
1810
|
+
# List Atlas Search indexes for a collection
|
|
1811
|
+
# Uses the $listSearchIndexes aggregation stage.
|
|
1812
|
+
# @param collection_name [String] the collection name
|
|
1813
|
+
# @return [Array<Hash>] array of search index definitions
|
|
1814
|
+
# @note Requires MongoDB Atlas or local Atlas deployment
|
|
1815
|
+
def list_search_indexes(collection_name)
|
|
1816
|
+
aggregate(collection_name, [{ "$listSearchIndexes" => {} }])
|
|
1817
|
+
end
|
|
1818
|
+
|
|
1819
|
+
# List regular MongoDB indexes for a collection.
|
|
1820
|
+
# Hits the system catalog via the driver's `indexes.list` and returns
|
|
1821
|
+
# the raw definitions — distinct from {.list_search_indexes}, which
|
|
1822
|
+
# only enumerates Atlas Search indexes. Operator-facing introspection
|
|
1823
|
+
# used by `Parse::Core::Describe`.
|
|
1824
|
+
#
|
|
1825
|
+
# @param collection_name [String] the Parse collection / class name
|
|
1826
|
+
# @return [Array<Hash>] each entry includes at least `"name"` and
|
|
1827
|
+
# `"key"` (`{ field => 1 | -1 | "text" | "2dsphere" }`), plus
|
|
1828
|
+
# driver-reported flags like `"unique"`, `"sparse"`,
|
|
1829
|
+
# `"partialFilterExpression"`, and `"expireAfterSeconds"` when set.
|
|
1830
|
+
def indexes(collection_name)
|
|
1831
|
+
collection(collection_name).indexes.to_a
|
|
1832
|
+
rescue StandardError => e
|
|
1833
|
+
# `listIndexes` raises NamespaceNotFound on collections that
|
|
1834
|
+
# haven't been created yet — treat as "no indexes" so describe
|
|
1835
|
+
# and plan paths degrade gracefully on empty databases.
|
|
1836
|
+
return [] if mongo_namespace_not_found?(e)
|
|
1837
|
+
raise
|
|
1838
|
+
end
|
|
1839
|
+
|
|
1840
|
+
# Per-index usage statistics via the `$indexStats` aggregation
|
|
1841
|
+
# stage. Returns a Hash keyed by index name with `{ops:, since:}`
|
|
1842
|
+
# for each — `ops` is the number of times the index has been
|
|
1843
|
+
# accessed since the last MongoDB restart, `since` is the timestamp
|
|
1844
|
+
# of that restart (i.e. the start of the counting window). Empty
|
|
1845
|
+
# Hash on access error so callers (e.g. `Model.describe(:indexes,
|
|
1846
|
+
# network: true, usage: true)`) degrade gracefully when the
|
|
1847
|
+
# authenticated role lacks `clusterMonitor` (the minimum privilege
|
|
1848
|
+
# `$indexStats` requires).
|
|
1849
|
+
#
|
|
1850
|
+
# **Admin-only.** This is a metadata-disclosure surface (which
|
|
1851
|
+
# indexes are hot fingerprints which classes hold interesting
|
|
1852
|
+
# data) and so requires explicit `master: true` to invoke. The
|
|
1853
|
+
# previous behavior hard-coded `master: true` internally, which
|
|
1854
|
+
# was a copy-paste-lethal pattern for any future row-returning
|
|
1855
|
+
# path. Callers without master scope raise `ArgumentError`
|
|
1856
|
+
# internally; that error is caught by the method's own
|
|
1857
|
+
# degrade-to-empty rescue so existing best-effort callers
|
|
1858
|
+
# (`Parse::Model.describe(:indexes, usage: true)`) continue to
|
|
1859
|
+
# surface `usage_available: false` instead of blowing up — but
|
|
1860
|
+
# the `ArgumentError` is the loud signal for anyone introducing
|
|
1861
|
+
# a new caller that forgets the opt-in. Direct callers that
|
|
1862
|
+
# disable the rescue (test mocks, callers wrapping with their
|
|
1863
|
+
# own error handling) will see the `ArgumentError` propagate.
|
|
1864
|
+
#
|
|
1865
|
+
# @param collection_name [String]
|
|
1866
|
+
# @param master [Boolean] explicit master-mode opt-in. Required.
|
|
1867
|
+
# @return [Hash{String => Hash}] `{ index_name => { ops:, since: } }`,
|
|
1868
|
+
# or `{}` when called without `master: true` (degrade-to-empty
|
|
1869
|
+
# rescue).
|
|
1870
|
+
def index_stats(collection_name, master: false)
|
|
1871
|
+
unless master == true
|
|
1872
|
+
raise ArgumentError,
|
|
1873
|
+
"Parse::MongoDB.index_stats is admin-only and requires `master: true`. " \
|
|
1874
|
+
"$indexStats discloses cluster metadata; pass `master: true` to confirm " \
|
|
1875
|
+
"the caller is authorized. Callers without master scope (e.g. agent " \
|
|
1876
|
+
"tools, request handlers) must not invoke this method."
|
|
1877
|
+
end
|
|
1878
|
+
results = aggregate(collection_name, [{ "$indexStats" => {} }], master: true)
|
|
1879
|
+
results.each_with_object({}) do |row, h|
|
|
1880
|
+
name = row["name"] || row[:name]
|
|
1881
|
+
next unless name
|
|
1882
|
+
accesses = row["accesses"] || row[:accesses] || {}
|
|
1883
|
+
h[name] = {
|
|
1884
|
+
ops: (accesses["ops"] || accesses[:ops]).to_i,
|
|
1885
|
+
since: accesses["since"] || accesses[:since],
|
|
1886
|
+
}
|
|
1887
|
+
end
|
|
1888
|
+
rescue StandardError
|
|
1889
|
+
# Lack of clusterMonitor / Atlas BI restriction / NamespaceNotFound
|
|
1890
|
+
# all surface here — `usage:` is best-effort by design.
|
|
1891
|
+
{}
|
|
1892
|
+
end
|
|
1893
|
+
|
|
1894
|
+
# Convert a MongoDB document to Parse REST API format
|
|
1895
|
+
# This transforms MongoDB's internal field names to Parse's format:
|
|
1896
|
+
# - _id -> objectId
|
|
1897
|
+
# - _created_at -> createdAt
|
|
1898
|
+
# - _updated_at -> updatedAt
|
|
1899
|
+
# - _p_fieldName -> fieldName (as pointer)
|
|
1900
|
+
# - _acl -> ACL (with r/w converted to read/write)
|
|
1901
|
+
# - Removes other internal fields (_rperm, _wperm, _hashed_password, etc.)
|
|
1902
|
+
#
|
|
1903
|
+
# @param doc [Hash] the MongoDB document
|
|
1904
|
+
# @param class_name [String] the Parse class name
|
|
1905
|
+
# @return [Hash] the Parse-formatted hash
|
|
1906
|
+
def convert_document_to_parse(doc, class_name = nil)
|
|
1907
|
+
return nil unless doc.is_a?(Hash)
|
|
1908
|
+
|
|
1909
|
+
result = {}
|
|
1910
|
+
|
|
1911
|
+
doc.each do |key, value|
|
|
1912
|
+
key_str = key.to_s
|
|
1913
|
+
|
|
1914
|
+
case key_str
|
|
1915
|
+
when "_id"
|
|
1916
|
+
# MongoDB _id becomes Parse objectId
|
|
1917
|
+
# Guard against BSON::ObjectId not being defined when mongo gem is not loaded
|
|
1918
|
+
result["objectId"] = if defined?(BSON::ObjectId) && value.is_a?(BSON::ObjectId)
|
|
1919
|
+
value.to_s
|
|
1920
|
+
else
|
|
1921
|
+
value
|
|
1922
|
+
end
|
|
1923
|
+
when "_created_at"
|
|
1924
|
+
# MongoDB _created_at becomes Parse createdAt
|
|
1925
|
+
result["createdAt"] = convert_date_to_parse(value)
|
|
1926
|
+
when "_updated_at"
|
|
1927
|
+
# MongoDB _updated_at becomes Parse updatedAt
|
|
1928
|
+
result["updatedAt"] = convert_date_to_parse(value)
|
|
1929
|
+
when /^_p_(.+)$/
|
|
1930
|
+
# Pointer fields: _p_author -> author
|
|
1931
|
+
field_name = $1
|
|
1932
|
+
result[field_name] = convert_pointer_to_parse(value)
|
|
1933
|
+
when "_acl"
|
|
1934
|
+
# Convert MongoDB ACL format (r/w) to Parse format (read/write)
|
|
1935
|
+
result["ACL"] = convert_acl_to_parse(value)
|
|
1936
|
+
when /^_included_(.+)$/
|
|
1937
|
+
# Included/resolved pointer field from $lookup - convert embedded document
|
|
1938
|
+
# This handles eager loading: _included_artist -> artist (as full object)
|
|
1939
|
+
field_name = $1
|
|
1940
|
+
if value.is_a?(Hash)
|
|
1941
|
+
# Recursively convert the embedded document to Parse format
|
|
1942
|
+
result[field_name] = convert_document_to_parse(value)
|
|
1943
|
+
elsif value.nil?
|
|
1944
|
+
# Preserve nil for unresolved optional relationships
|
|
1945
|
+
result[field_name] = nil
|
|
1946
|
+
else
|
|
1947
|
+
result[field_name] = value
|
|
1948
|
+
end
|
|
1949
|
+
when /^_include_id_/
|
|
1950
|
+
# Skip temporary lookup ID fields (used internally for $lookup)
|
|
1951
|
+
next
|
|
1952
|
+
when "_rperm", "_wperm", "_hashed_password", "_email_verify_token",
|
|
1953
|
+
"_perishable_token", "_tombstone", "_failed_login_count",
|
|
1954
|
+
"_account_lockout_expires_at", "_session_token"
|
|
1955
|
+
# Skip internal Parse Server fields (not needed since we use _acl)
|
|
1956
|
+
next
|
|
1957
|
+
when /^_/
|
|
1958
|
+
# Skip other internal fields starting with underscore
|
|
1959
|
+
next
|
|
1960
|
+
else
|
|
1961
|
+
# Regular fields - recursively convert nested documents
|
|
1962
|
+
result[key_str] = convert_value_to_parse(value)
|
|
1963
|
+
end
|
|
1964
|
+
end
|
|
1965
|
+
|
|
1966
|
+
# Add className if provided
|
|
1967
|
+
result["className"] = class_name if class_name
|
|
1968
|
+
|
|
1969
|
+
result
|
|
1970
|
+
end
|
|
1971
|
+
|
|
1972
|
+
# Convert multiple MongoDB documents to Parse format
|
|
1973
|
+
# @param docs [Array<Hash>] the MongoDB documents
|
|
1974
|
+
# @param class_name [String] the Parse class name
|
|
1975
|
+
# @return [Array<Hash>] the Parse-formatted hashes
|
|
1976
|
+
def convert_documents_to_parse(docs, class_name = nil)
|
|
1977
|
+
docs.map { |doc| convert_document_to_parse(doc, class_name) }
|
|
1978
|
+
end
|
|
1979
|
+
|
|
1980
|
+
# Convert a raw MongoDB aggregation row, coercing values (BSON ObjectIds,
|
|
1981
|
+
# dates, nested documents) but preserving all field names including +_id+.
|
|
1982
|
+
# Unlike {.convert_document_to_parse}, this does NOT rename +_id+ to
|
|
1983
|
+
# +objectId+, because aggregation +$group+ stages reuse +_id+ as the
|
|
1984
|
+
# group key (e.g. a pointer string like +"Team$abc"+) rather than as a
|
|
1985
|
+
# Parse object identifier.
|
|
1986
|
+
#
|
|
1987
|
+
# @param doc [Hash] a raw MongoDB aggregation result row
|
|
1988
|
+
# @return [Hash] the coerced hash with stringified keys
|
|
1989
|
+
def convert_aggregation_document(doc)
|
|
1990
|
+
return nil unless doc.is_a?(Hash)
|
|
1991
|
+
doc.each_with_object({}) do |(key, value), result|
|
|
1992
|
+
result[key.to_s] = convert_value_to_parse(value)
|
|
1993
|
+
end
|
|
1994
|
+
end
|
|
1995
|
+
|
|
1996
|
+
# Convert a date value to a UTC Time object suitable for MongoDB queries.
|
|
1997
|
+
# MongoDB stores all dates in UTC, so this helper ensures consistent date handling
|
|
1998
|
+
# when building aggregation pipelines or direct queries.
|
|
1999
|
+
#
|
|
2000
|
+
# @param value [Date, Time, DateTime, String, nil] the date value to convert
|
|
2001
|
+
# @return [Time, nil] a UTC Time object, or nil if value is nil
|
|
2002
|
+
# @raise [ArgumentError] if the value cannot be parsed as a date
|
|
2003
|
+
#
|
|
2004
|
+
# @example Converting different date types
|
|
2005
|
+
# Parse::MongoDB.to_mongodb_date(Date.new(2024, 1, 15))
|
|
2006
|
+
# # => 2024-01-15 00:00:00 UTC
|
|
2007
|
+
#
|
|
2008
|
+
# Parse::MongoDB.to_mongodb_date(Time.now)
|
|
2009
|
+
# # => 2024-11-30 12:30:45 UTC (converted to UTC)
|
|
2010
|
+
#
|
|
2011
|
+
# Parse::MongoDB.to_mongodb_date("2024-01-15")
|
|
2012
|
+
# # => 2024-01-15 00:00:00 UTC
|
|
2013
|
+
#
|
|
2014
|
+
# Parse::MongoDB.to_mongodb_date("2024-01-15T10:30:00Z")
|
|
2015
|
+
# # => 2024-01-15 10:30:00 UTC
|
|
2016
|
+
#
|
|
2017
|
+
# @example Using in aggregation pipelines
|
|
2018
|
+
# cutoff = Parse::MongoDB.to_mongodb_date(Date.today - 30)
|
|
2019
|
+
# pipeline = [{ "$match" => { "createdAt" => { "$gte" => cutoff } } }]
|
|
2020
|
+
# results = Song.query.aggregate(pipeline, mongo_direct: true).results
|
|
2021
|
+
#
|
|
2022
|
+
# @example Using with query constraints
|
|
2023
|
+
# # For date comparisons in queries, this ensures UTC consistency
|
|
2024
|
+
# start_date = Parse::MongoDB.to_mongodb_date(params[:start_date])
|
|
2025
|
+
# end_date = Parse::MongoDB.to_mongodb_date(params[:end_date])
|
|
2026
|
+
# songs = Song.query(:release_date.gte => start_date, :release_date.lt => end_date)
|
|
2027
|
+
def to_mongodb_date(value)
|
|
2028
|
+
return nil if value.nil?
|
|
2029
|
+
|
|
2030
|
+
case value
|
|
2031
|
+
when ::Time
|
|
2032
|
+
value.utc
|
|
2033
|
+
when ::DateTime
|
|
2034
|
+
value.to_time.utc
|
|
2035
|
+
when ::Date
|
|
2036
|
+
# Convert Date to midnight UTC
|
|
2037
|
+
::Time.utc(value.year, value.month, value.day)
|
|
2038
|
+
when ::String
|
|
2039
|
+
# Parse string dates - try ISO 8601 first, then Date.parse
|
|
2040
|
+
begin
|
|
2041
|
+
if value =~ /T/
|
|
2042
|
+
# ISO 8601 with time component
|
|
2043
|
+
::Time.parse(value).utc
|
|
2044
|
+
else
|
|
2045
|
+
# Date-only string, convert to midnight UTC
|
|
2046
|
+
date = ::Date.parse(value)
|
|
2047
|
+
::Time.utc(date.year, date.month, date.day)
|
|
2048
|
+
end
|
|
2049
|
+
rescue ::ArgumentError => e
|
|
2050
|
+
raise ::ArgumentError, "Cannot parse '#{value}' as a date: #{e.message}"
|
|
2051
|
+
end
|
|
2052
|
+
when ::Integer
|
|
2053
|
+
# Assume Unix timestamp
|
|
2054
|
+
::Time.at(value).utc
|
|
2055
|
+
else
|
|
2056
|
+
raise ::ArgumentError, "Cannot convert #{value.class} to MongoDB date. " \
|
|
2057
|
+
"Expected Date, Time, DateTime, String, or Integer."
|
|
2058
|
+
end
|
|
2059
|
+
end
|
|
2060
|
+
|
|
2061
|
+
private
|
|
2062
|
+
|
|
2063
|
+
# MongoDB error code for MaxTimeMSExpired
|
|
2064
|
+
MONGO_MAX_TIME_MS_EXPIRED_CODE = 50
|
|
2065
|
+
|
|
2066
|
+
# Inspect a driver exception and raise {ExecutionTimeout} if it carries
|
|
2067
|
+
# error code 50 (MaxTimeMSExpired). Otherwise, the original exception is
|
|
2068
|
+
# re-raised by the caller.
|
|
2069
|
+
#
|
|
2070
|
+
# @param err [StandardError] the exception to inspect
|
|
2071
|
+
# @param collection_name [String] the collection name (for the timeout error)
|
|
2072
|
+
# @param max_time_ms [Integer, nil] the budget that was exceeded (may be nil)
|
|
2073
|
+
# @return [void]
|
|
2074
|
+
# @raise [Parse::MongoDB::ExecutionTimeout] when code == 50
|
|
2075
|
+
def raise_if_timeout!(err, collection_name, max_time_ms)
|
|
2076
|
+
return unless defined?(::Mongo::Error::OperationFailure)
|
|
2077
|
+
return unless err.is_a?(::Mongo::Error::OperationFailure)
|
|
2078
|
+
return unless err.respond_to?(:code) && err.code == MONGO_MAX_TIME_MS_EXPIRED_CODE
|
|
2079
|
+
|
|
2080
|
+
raise ExecutionTimeout.new(
|
|
2081
|
+
collection_name: collection_name.to_s,
|
|
2082
|
+
max_time_ms: max_time_ms,
|
|
2083
|
+
)
|
|
2084
|
+
end
|
|
2085
|
+
|
|
2086
|
+
def extract_database_from_uri(uri)
|
|
2087
|
+
return nil unless uri
|
|
2088
|
+
# Extract database name from MongoDB URI
|
|
2089
|
+
# Format: mongodb://[user:pass@]host[:port]/database[?options]
|
|
2090
|
+
if uri =~ %r{mongodb(?:\+srv)?://[^/]+/([^?]+)}
|
|
2091
|
+
$1
|
|
2092
|
+
end
|
|
2093
|
+
end
|
|
2094
|
+
|
|
2095
|
+
def convert_date_to_parse(value)
|
|
2096
|
+
case value
|
|
2097
|
+
when Time, DateTime
|
|
2098
|
+
{ "__type" => "Date", "iso" => value.utc.iso8601(3) }
|
|
2099
|
+
when Date
|
|
2100
|
+
{ "__type" => "Date", "iso" => value.to_time.utc.iso8601(3) }
|
|
2101
|
+
when String
|
|
2102
|
+
# Already a string date, wrap in Parse format
|
|
2103
|
+
{ "__type" => "Date", "iso" => value }
|
|
2104
|
+
else
|
|
2105
|
+
value
|
|
2106
|
+
end
|
|
2107
|
+
end
|
|
2108
|
+
|
|
2109
|
+
def convert_pointer_to_parse(value)
|
|
2110
|
+
return nil if value.nil?
|
|
2111
|
+
|
|
2112
|
+
if value.is_a?(String) && value.include?("$")
|
|
2113
|
+
# Parse pointer format: "ClassName$objectId"
|
|
2114
|
+
class_name, object_id = value.split("$", 2)
|
|
2115
|
+
{
|
|
2116
|
+
"__type" => "Pointer",
|
|
2117
|
+
"className" => class_name,
|
|
2118
|
+
"objectId" => object_id,
|
|
2119
|
+
}
|
|
2120
|
+
else
|
|
2121
|
+
value
|
|
2122
|
+
end
|
|
2123
|
+
end
|
|
2124
|
+
|
|
2125
|
+
# Convert MongoDB ACL format to Parse REST API format
|
|
2126
|
+
# MongoDB uses short keys: { "*": { r: true, w: false }, "userId": { r: true, w: true } }
|
|
2127
|
+
# Parse uses full keys: { "*": { read: true }, "userId": { read: true, write: true } }
|
|
2128
|
+
# @param value [Hash] the MongoDB ACL hash
|
|
2129
|
+
# @return [Hash] the Parse-formatted ACL hash
|
|
2130
|
+
def convert_acl_to_parse(value)
|
|
2131
|
+
return nil if value.nil?
|
|
2132
|
+
return value unless value.is_a?(Hash)
|
|
2133
|
+
|
|
2134
|
+
result = {}
|
|
2135
|
+
value.each do |entity, permissions|
|
|
2136
|
+
entity_str = entity.to_s
|
|
2137
|
+
next unless permissions.is_a?(Hash)
|
|
2138
|
+
|
|
2139
|
+
parsed_perms = {}
|
|
2140
|
+
# Convert r -> read, w -> write
|
|
2141
|
+
if permissions["r"] == true || permissions[:r] == true
|
|
2142
|
+
parsed_perms["read"] = true
|
|
2143
|
+
end
|
|
2144
|
+
if permissions["w"] == true || permissions[:w] == true
|
|
2145
|
+
parsed_perms["write"] = true
|
|
2146
|
+
end
|
|
2147
|
+
# Also handle if already in full format
|
|
2148
|
+
if permissions["read"] == true || permissions[:read] == true
|
|
2149
|
+
parsed_perms["read"] = true
|
|
2150
|
+
end
|
|
2151
|
+
if permissions["write"] == true || permissions[:write] == true
|
|
2152
|
+
parsed_perms["write"] = true
|
|
2153
|
+
end
|
|
2154
|
+
|
|
2155
|
+
result[entity_str] = parsed_perms if parsed_perms.any?
|
|
2156
|
+
end
|
|
2157
|
+
result
|
|
2158
|
+
end
|
|
2159
|
+
|
|
2160
|
+
def convert_value_to_parse(value)
|
|
2161
|
+
case value
|
|
2162
|
+
when Hash
|
|
2163
|
+
if value["__type"]
|
|
2164
|
+
# Already a Parse type, return as-is
|
|
2165
|
+
value
|
|
2166
|
+
elsif value[:__type]
|
|
2167
|
+
# Symbol keys, convert to string keys
|
|
2168
|
+
value.transform_keys(&:to_s)
|
|
2169
|
+
elsif (geojson = detect_geojson_geometry(value))
|
|
2170
|
+
# MongoDB stores GeoJSON natively for any 2dsphere-indexed
|
|
2171
|
+
# field. Translate the two geometries Parse Server models
|
|
2172
|
+
# (Point/Polygon) back into their Parse wire-format hashes so
|
|
2173
|
+
# the caller's downstream code can treat mongo-direct results
|
|
2174
|
+
# identically to Parse REST responses. Other geometry types
|
|
2175
|
+
# (LineString, MultiPolygon, etc.) are left as raw GeoJSON
|
|
2176
|
+
# hashes since Parse Server has no schema slot for them.
|
|
2177
|
+
geojson
|
|
2178
|
+
else
|
|
2179
|
+
# Regular hash, recursively convert
|
|
2180
|
+
value.transform_values { |v| convert_value_to_parse(v) }
|
|
2181
|
+
end
|
|
2182
|
+
when Array
|
|
2183
|
+
value.map { |v| convert_value_to_parse(v) }
|
|
2184
|
+
when Time, DateTime
|
|
2185
|
+
convert_date_to_parse(value)
|
|
2186
|
+
when Date
|
|
2187
|
+
convert_date_to_parse(value)
|
|
2188
|
+
else
|
|
2189
|
+
# Handle BSON::ObjectId if mongo gem is loaded
|
|
2190
|
+
if defined?(BSON::ObjectId) && value.is_a?(BSON::ObjectId)
|
|
2191
|
+
value.to_s
|
|
2192
|
+
else
|
|
2193
|
+
value
|
|
2194
|
+
end
|
|
2195
|
+
end
|
|
2196
|
+
end
|
|
2197
|
+
|
|
2198
|
+
# Coerce a user-supplied point value to a GeoJSON `Point` literal.
|
|
2199
|
+
# Accepts a {Parse::GeoPoint}, an already-shaped GeoJSON Point
|
|
2200
|
+
# Hash, or a `[longitude, latitude]` numeric Array.
|
|
2201
|
+
# @!visibility private
|
|
2202
|
+
def geojson_point_for(value)
|
|
2203
|
+
case value
|
|
2204
|
+
when Parse::GeoPoint
|
|
2205
|
+
{ type: "Point", coordinates: [value.longitude, value.latitude] }
|
|
2206
|
+
when Hash
|
|
2207
|
+
hash = value.respond_to?(:symbolize_keys) ? value.symbolize_keys : value
|
|
2208
|
+
type = hash[:type] || hash["type"]
|
|
2209
|
+
coords = hash[:coordinates] || hash["coordinates"]
|
|
2210
|
+
unless type.to_s == "Point" && coords.is_a?(Array) && coords.length == 2 &&
|
|
2211
|
+
coords.all? { |n| n.is_a?(Numeric) }
|
|
2212
|
+
raise ArgumentError, "[Parse::MongoDB.geo_near] `near:` hash must be a GeoJSON Point."
|
|
2213
|
+
end
|
|
2214
|
+
{ type: "Point", coordinates: [coords[0].to_f, coords[1].to_f] }
|
|
2215
|
+
when Array
|
|
2216
|
+
unless value.length == 2 && value.all? { |n| n.is_a?(Numeric) }
|
|
2217
|
+
raise ArgumentError, "[Parse::MongoDB.geo_near] `near:` array must be [longitude, latitude]."
|
|
2218
|
+
end
|
|
2219
|
+
{ type: "Point", coordinates: [value[0].to_f, value[1].to_f] }
|
|
2220
|
+
else
|
|
2221
|
+
raise ArgumentError, "[Parse::MongoDB.geo_near] `near:` must be a Parse::GeoPoint, " \
|
|
2222
|
+
"GeoJSON Point Hash, or [longitude, latitude] Array."
|
|
2223
|
+
end
|
|
2224
|
+
end
|
|
2225
|
+
|
|
2226
|
+
METERS_PER_KILOMETER = 1_000.0
|
|
2227
|
+
METERS_PER_MILE = 1_609.344
|
|
2228
|
+
|
|
2229
|
+
# Convert a user-supplied distance + unit to meters (the wire unit
|
|
2230
|
+
# for `$geoNear` against a 2dsphere index).
|
|
2231
|
+
# @!visibility private
|
|
2232
|
+
def convert_distance_to_meters(value, unit)
|
|
2233
|
+
return value.to_f if unit == :meters || unit.nil?
|
|
2234
|
+
case unit
|
|
2235
|
+
when :km, :kilometers then value.to_f * METERS_PER_KILOMETER
|
|
2236
|
+
when :miles then value.to_f * METERS_PER_MILE
|
|
2237
|
+
else
|
|
2238
|
+
raise ArgumentError, "[Parse::MongoDB.geo_near] `unit:` must be :meters, :km, or :miles."
|
|
2239
|
+
end
|
|
2240
|
+
end
|
|
2241
|
+
|
|
2242
|
+
# Detect a GeoJSON Point or Polygon geometry hash and convert it to
|
|
2243
|
+
# the equivalent Parse REST wire-format hash. Returns nil when the
|
|
2244
|
+
# input is not a recognized geometry, leaving the caller free to
|
|
2245
|
+
# treat it as a generic hash.
|
|
2246
|
+
# @return [Hash, nil]
|
|
2247
|
+
def detect_geojson_geometry(value)
|
|
2248
|
+
type = value["type"] || value[:type]
|
|
2249
|
+
coords = value["coordinates"] || value[:coordinates]
|
|
2250
|
+
return nil unless type.is_a?(String) && coords.is_a?(Array)
|
|
2251
|
+
|
|
2252
|
+
case type
|
|
2253
|
+
when "Point"
|
|
2254
|
+
return nil unless coords.length == 2 && coords.all? { |n| n.is_a?(Numeric) }
|
|
2255
|
+
lng, lat = coords
|
|
2256
|
+
{ "__type" => "GeoPoint", "latitude" => lat.to_f, "longitude" => lng.to_f }
|
|
2257
|
+
when "Polygon"
|
|
2258
|
+
# GeoJSON Polygon outer ring -> Parse [lat, lng] pairs.
|
|
2259
|
+
return nil unless coords.first.is_a?(Array)
|
|
2260
|
+
pairs = coords.first.map do |pair|
|
|
2261
|
+
return nil unless pair.is_a?(Array) && pair.length == 2 &&
|
|
2262
|
+
pair[0].is_a?(Numeric) && pair[1].is_a?(Numeric)
|
|
2263
|
+
[pair[1].to_f, pair[0].to_f]
|
|
2264
|
+
end
|
|
2265
|
+
{ "__type" => "Polygon", "coordinates" => pairs }
|
|
2266
|
+
end
|
|
2267
|
+
end
|
|
2268
|
+
|
|
2269
|
+
public
|
|
2270
|
+
|
|
2271
|
+
# Walk a filter hash or aggregation pipeline (Hash or Array) and
|
|
2272
|
+
# raise {DeniedOperator} if any nested key matches an entry in
|
|
2273
|
+
# {Parse::PipelineSecurity::DENIED_OPERATORS}.
|
|
2274
|
+
#
|
|
2275
|
+
# @param node [Hash, Array, Object] structure to walk.
|
|
2276
|
+
# @param allow_internal_fields [Boolean] when true, skip the
|
|
2277
|
+
# {Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST} check.
|
|
2278
|
+
# Forwarded to {Parse::PipelineSecurity.validate_filter!}. The
|
|
2279
|
+
# DENIED_OPERATORS walk still runs. Intended only for callers
|
|
2280
|
+
# that built the pipeline via {Parse::Query}'s own constraint
|
|
2281
|
+
# DSL (e.g. {Parse::Query#readable_by_role}); raw user-supplied
|
|
2282
|
+
# pipelines (Agent MCP tools) must keep the default +false+.
|
|
2283
|
+
#
|
|
2284
|
+
# Public for testability and for callers that want to validate
|
|
2285
|
+
# input before forwarding to {.find} / {.aggregate}.
|
|
2286
|
+
def assert_no_denied_operators!(node, allow_internal_fields: false)
|
|
2287
|
+
Parse::PipelineSecurity.validate_filter!(node, allow_internal_fields: allow_internal_fields)
|
|
2288
|
+
nil
|
|
2289
|
+
rescue Parse::PipelineSecurity::Error => e
|
|
2290
|
+
raise DeniedOperator, e.message
|
|
2291
|
+
end
|
|
2292
|
+
end
|
|
2293
|
+
|
|
2294
|
+
# Initialize defaults
|
|
2295
|
+
@enabled = false
|
|
2296
|
+
@uri = nil
|
|
2297
|
+
@database = nil
|
|
2298
|
+
@client = nil
|
|
2299
|
+
end
|
|
2300
|
+
end
|