Module: Parse::MongoDB
- Defined in:
- lib/parse/mongodb.rb
Overview
Requires the ‘mongo’ gem to be installed. Add to your Gemfile: gem ‘mongo’, ‘~> 2.18’
Direct MongoDB access module for bypassing Parse Server. Provides read-only direct access to MongoDB for performance-critical queries.
Field Name Conventions
When writing aggregation pipelines for direct MongoDB queries, use MongoDB’s native field naming conventions:
-
*Regular fields*: Use camelCase (e.g.,
releaseDate,playCount,firstName) -
*Pointer fields*: Use
pprefix (e.g.,_p_author,_p_album) -
*Built-in dates*: Use
_created_atand_updated_at -
*Field references*: Use $fieldName syntax (e.g., $releaseDate, $_p_author)
Results are automatically converted to Ruby-friendly format:
-
Field names converted to snake_case (
totalPlays→total_plays) -
Custom aggregation results wrapped in
AggregationResultfor method access -
Parse documents returned as proper
Parse::Objectinstances
Date Comparisons
MongoDB stores dates in UTC. When comparing dates in aggregation pipelines:
-
Use Ruby
Timeobjects for comparisons (automatically converted to BSON dates) -
Ruby
Dateobjects (without time) are stored as midnight UTC -
For accurate date-only comparisons, use Time.utc(year, month, day)
Defined Under Namespace
Classes: ConnectionError, DeniedOperator, ExecutionTimeout, ForbiddenCollection, GemNotAvailable, MutationsDisabled, NotEnabled, WriterNotConfigured, WriterRoleTooPermissive
Constant Summary collapse
- DEFAULT_FIND_LIMIT =
Threshold above which ‘Parse::MongoDB.find` emits a deprecation warning when called without an explicit `:limit` option. A future major release will enforce this as a hard default limit. Callers should pass an explicit `:limit` (including `:limit => 0` for unbounded) to silence the warning.
1000- ENV_URI_KEYS =
Environment variable names consulted (in priority order) when configure is called without an explicit ‘uri:` argument. `ANALYTICS_DATABASE_URI` is listed first so deployments can point direct-read traffic at a dedicated analytics replica without disturbing the primary `DATABASE_URI` that Parse Server uses for writes. `DATABASE_URI` is the fallback for deployments where the direct path reads from the same node as Parse Server.
%w[ANALYTICS_DATABASE_URI DATABASE_URI].freeze
- MUTATION_ENV_KEY =
Environment variable consulted as part of the triple gate for index mutations. The check is performed on every call (not just at configure time) so a SIGHUP / process-supervisor that flips the variable can revoke without restart.
"PARSE_MONGO_INDEX_MUTATIONS"- PARSE_INTERNAL_CLASSES =
Parse-internal collections that must not receive index mutations without explicit ‘allow_system_classes: true`. A unique index on `_Session.session_token`, for example, would break auth on the first duplicate token write.
%w[ _User _Role _Session _Installation _Audience _Idempotency _PushStatus _JobStatus _Hooks _GlobalConfig _SCHEMA ].freeze
- WRITER_ALLOWED_ACTIONS =
Mongo privilege actions the writer role MAY hold. Anything outside this set causes configure_writer to refuse with WriterRoleTooPermissive. Reads are allowed; mutations are scoped to index management only.
The Atlas Search actions (‘createSearchIndexes`, `dropSearchIndex`, `updateSearchIndex`, `listSearchIndexes`) are included so a writer role provisioned for search-index management passes the privilege probe. Operators who do not grant those actions in their Mongo role simply cannot invoke the search-index primitives — the SDK allowlist does not auto-grant; it only refuses to reject roles that legitimately hold these specific actions.
%w[ createIndex dropIndex createSearchIndexes dropSearchIndex updateSearchIndex listSearchIndexes listIndexes listCollections collStats find listDatabases connPoolStats serverStatus ].freeze
- WRITE_ACTIONS =
MongoDB privilege “actions” that indicate write capability. Used by read_only? to classify the authenticated user’s role.
%w[ insert update remove createCollection dropCollection createIndex dropIndex applyOps dropDatabase renameCollectionSameDB enableSharding ].freeze
- DENIED_OPERATORS =
Deprecated.
Retained for backwards compatibility. The canonical list now lives in PipelineSecurity::DENIED_OPERATORS.
Parse::PipelineSecurity::DENIED_OPERATORS
Class Attribute Summary collapse
-
.client ⇒ Mongo::Client
readonly
Get or create the MongoDB client.
-
.database ⇒ String
MongoDB database name (extracted from URI or set manually).
-
.enabled ⇒ Boolean
Feature flag to enable/disable direct MongoDB queries.
-
.index_mutations_enabled ⇒ Boolean
Ruby-side gate (one of the three required for mutations).
-
.uri ⇒ String
MongoDB connection URI.
Class Method Summary collapse
-
.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) ⇒ Array<Hash>
Execute an aggregation pipeline directly on MongoDB.
-
.assert_mutations_allowed! ⇒ Object
Run all three gates.
-
.assert_no_denied_operators!(node, allow_internal_fields: false) ⇒ Object
Walk a filter hash or aggregation pipeline (Hash or Array) and raise DeniedOperator if any nested key matches an entry in PipelineSecurity::DENIED_OPERATORS.
-
.available? ⇒ Boolean
Check if direct MongoDB queries are available and enabled.
-
.collection(name) ⇒ Mongo::Collection
Get a MongoDB collection.
-
.configure(uri: nil, enabled: true, database: nil, verify_role: true) ⇒ Object
Configure direct MongoDB access.
-
.configure_writer(uri:, enabled: true, verify_role: true) ⇒ Object
Configure the writer connection used for index mutations.
-
.convert_aggregation_document(doc) ⇒ Hash
Convert a raw MongoDB aggregation row, coercing values (BSON ObjectIds, dates, nested documents) but preserving all field names including
_id. -
.convert_document_to_parse(doc, class_name = nil) ⇒ Hash
Convert a MongoDB document to Parse REST API format This transforms MongoDB’s internal field names to Parse’s format: - _id -> objectId - _created_at -> createdAt - _updated_at -> updatedAt - _p_fieldName -> fieldName (as pointer) - _acl -> ACL (with r/w converted to read/write) - Removes other internal fields (_rperm, _wperm, _hashed_password, etc.).
-
.convert_documents_to_parse(docs, class_name = nil) ⇒ Array<Hash>
Convert multiple MongoDB documents to Parse format.
-
.create_index(collection_name, keys, name: nil, unique: false, sparse: false, partial_filter: nil, expire_after: nil, allow_system_classes: false) ⇒ Symbol
Create an index on the named collection.
-
.create_search_index(collection_name, name, definition, allow_system_classes: false) ⇒ Symbol
Create an Atlas Search index.
-
.drop_index(collection_name, name, confirm:, allow_system_classes: false) ⇒ Symbol
Drop a named index.
-
.drop_search_index(collection_name, name, confirm:, allow_system_classes: false) ⇒ Symbol
Drop a named Atlas Search index.
-
.enabled? ⇒ Boolean
Check if direct queries are enabled.
-
.find(collection_name, filter = {}, **options) ⇒ Array<Hash>
Execute a find query directly on MongoDB.
-
.gem_available? ⇒ Boolean
Check if the mongo gem is available.
-
.geo_near(collection_name, near:, distance_field: "distance", max_distance: nil, min_distance: nil, unit: :meters, spherical: true, query: nil, include_locs: nil, key: nil, distance_multiplier: nil, limit: nil, additional_stages: [], max_time_ms: nil, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil) ⇒ Array<Hash>
Execute a ‘$geoNear` aggregation against a collection, returning documents sorted by proximity to `near` along with their computed distance.
-
.index_stats(collection_name, master: false) ⇒ Hash{String => Hash}
Per-index usage statistics via the ‘$indexStats` aggregation stage.
-
.indexes(collection_name) ⇒ Array<Hash>
List regular MongoDB indexes for a collection.
-
.list_search_indexes(collection_name) ⇒ Array<Hash>
List Atlas Search indexes for a collection Uses the $listSearchIndexes aggregation stage.
-
.mutations_env_enabled? ⇒ Boolean
True iff ‘ENV == “1”`.
-
.normalize_read_preference(value) ⇒ Symbol?
Normalize a Parse-style read-preference value into the Mongo Ruby driver’s ‘:mode` symbol.
-
.read_only? ⇒ Boolean?
Probe whether the authenticated user on the configured URI has any write privileges.
-
.require_gem! ⇒ Object
Ensure mongo gem is loaded, raise error if not.
-
.reset! ⇒ Object
Reset the client connection (useful for testing).
-
.reset_writer! ⇒ Object
Reset the writer connection and clear gate state.
-
.resolve_uri_from_env ⇒ String?
The first env-var URI found, in ENV_URI_KEYS priority order, or nil if none is set.
-
.role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil) ⇒ Set<String>?
Resolve every role name a user inherits via a single ‘$graphLookup` aggregation against the Parse role-membership and role-inheritance join tables.
-
.to_mongodb_date(value) ⇒ Time?
Convert a date value to a UTC Time object suitable for MongoDB queries.
-
.update_search_index(collection_name, name, definition, allow_system_classes: false) ⇒ Symbol
Replace the definition of an existing Atlas Search index.
-
.users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil) ⇒ Set<String>?
Resolve every ‘_User.objectId` whose effective role set includes `role_id` — i.e., direct members of `role_id` PLUS direct members of any descendant role in `role_id`’s inheritance subtree.
-
.warn_if_writeable_role! ⇒ Object
private
Emit a warning when MongoDB.read_only? reports a writeable role.
-
.writer_configured? ⇒ Boolean
True when MongoDB.configure_writer has been called with ‘enabled: true` and the connection is reachable.
-
.writer_indexes(collection_name, allow_system_classes: false) ⇒ Array<Hash>
List indexes on a collection via the WRITER connection.
-
.writer_search_indexes(collection_name, allow_system_classes: false) ⇒ Array<Hash>
List Atlas Search indexes via the WRITER connection.
Class Attribute Details
.client ⇒ Mongo::Client (readonly)
Get or create the MongoDB client
208 209 210 |
# File 'lib/parse/mongodb.rb', line 208 def client @client end |
.database ⇒ String
MongoDB database name (extracted from URI or set manually).
203 204 205 |
# File 'lib/parse/mongodb.rb', line 203 def database @database end |
.enabled ⇒ Boolean
Feature flag to enable/disable direct MongoDB queries.
193 194 195 |
# File 'lib/parse/mongodb.rb', line 193 def enabled @enabled end |
.index_mutations_enabled ⇒ Boolean
Ruby-side gate (one of the three required for mutations). Default ‘false`. Must be flipped to `true` explicitly in code (typically in a rake task initializer, never in a web-process initializer).
433 434 435 |
# File 'lib/parse/mongodb.rb', line 433 def index_mutations_enabled @index_mutations_enabled end |
.uri ⇒ String
MongoDB connection URI.
198 199 200 |
# File 'lib/parse/mongodb.rb', line 198 def uri @uri end |
Class Method Details
.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) ⇒ Array<Hash>
Execute an aggregation pipeline directly on MongoDB
1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 |
# File 'lib/parse/mongodb.rb', line 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) # Resolve auth kwargs into a Parse::ACLScope::Resolution. The # call MUTATES the temporary kwargs hash (popping the auth # entries) before the resolution; we package them into a hash # here only so the shared helper can stay path-agnostic. The # hash is local and discarded after the call. auth_kwargs = { session_token: session_token, master: master, acl_user: acl_user, acl_role: acl_role, }.compact resolution = Parse::ACLScope.resolve!(auth_kwargs, method_name: :aggregate) # Validate BEFORE rewrite so the security denylist is applied to the # caller's original pipeline (which an attacker controls), not to # the gem-rewritten form (which it doesn't). Matches the ordering # used by Parse::Query#aggregate and Parse::Agent::Tools.aggregate. assert_no_denied_operators!(pipeline, allow_internal_fields: allow_internal_fields) # Wave-3 TRACK-CLP-4: refuse any caller-supplied `$<field>` # reference that names a protectedField for the queried class # in the current scope. The post-fetch redact strips by NAME, # so a pipeline can launder a protected value through a # `$project: { renamed: "$ssn" }` (and similar) clauses and # bypass the strip silently. Catching the reference here at # parse-time refuses the join with `Parse::CLPScope::Denied` # so the bypass surfaces as an explicit error rather than a # quiet exfiltration. Master mode short-circuits inside the # scanner (no protected set on master). Parse::PipelineSecurity.refuse_protected_field_references!( pipeline, collection_name, resolution, ) pipeline = Parse::LookupRewriter.auto_rewrite( pipeline, class_name: collection_name, enabled: rewrite_lookups, ) # Three-layer ACL simulation on the mongo-direct path: # # 1. Top-level $match: filter the queried collection's rows by # the session's _rperm allow-set. Mirrors Parse Server's # REST find behavior. # 2. Pipeline rewriter: every $lookup / $unionWith / $graphLookup / # $facet sub-pipeline gets the same _rperm filter embedded # so joined rows from other collections are filtered at the # database. Without this, includes/joins would silently leak # rows the requesting session has no permission to read. # 3. Post-fetch redaction: walk the returned documents and # scrub any embedded sub-documents whose stored _rperm # doesn't match the perms set. Catches cases the rewriter # can't reach (e.g., :object columns embedding raw pointer # hashes, or caller-supplied $lookup stages that escaped # rewriting because of unusual shapes). # # The security validator already ran on the caller's original # pipeline above; the injected stages reference `_rperm` but # are SDK-generated (not attacker-controlled), so no # re-validation is needed before they're handed to MongoDB. if (acl_stage = Parse::ACLScope.match_stage_for(resolution)) pipeline = [acl_stage] + pipeline end pipeline = Parse::ACLScope.rewrite_pipeline(pipeline, resolution) # Class-Level Permissions boundary check. Parse Server's REST # aggregate endpoint runs master-key-only and does NOT enforce # CLP; the mongo-direct path bypasses Parse Server entirely so # the SDK is the only enforcement layer. Refuse the call when # the resolved scope can't `find` on the collection. Master- # key (resolution.master? / nil permission_strings) bypasses. perms_for_clp = resolution&. unless resolution.nil? || resolution.master? unless Parse::CLPScope.permits?(collection_name, :find, perms_for_clp) raise Parse::CLPScope::Denied.new( collection_name, :find, "CLP refuses find on '#{collection_name}' for the current scope.", ) end end # Resolve the pointerFields constraint (if any) BEFORE running # the query — we apply the filter post-fetch but want to fail # loudly when the scope can't satisfy the constraint at all # (acl_role-only / public agents have no user_id to match). pointer_fields = nil unless resolution.nil? || resolution.master? pointer_fields = Parse::CLPScope.pointer_fields_for(collection_name, :find) if pointer_fields && resolution.user_id.nil? raise Parse::CLPScope::Denied.new( collection_name, :find, "CLP requires user identity (pointerFields=#{pointer_fields.inspect}) " \ "but the current scope has no user_id.", ) end end agg_opts = {} agg_opts[:max_time_ms] = max_time_ms if max_time_ms coll = collection(collection_name) if (mode = normalize_read_preference(read_preference)) coll = coll.with(read: { mode: mode }) end results = coll.aggregate(pipeline, agg_opts).to_a Parse::ACLScope.redact_results!(results, resolution) # Post-fetch pointerFields filter: drop rows where none of the # named pointer fields references the requesting user. Skipped # for master-key and when the CLP has no pointerFields entry. if pointer_fields results = Parse::CLPScope.filter_by_pointer_fields( results, pointer_fields, resolution.user_id, ) end # Protected fields stripping. Resolve the field set per the # session's claim composition and walk-delete from every # row + embedded sub-document. Top-level $project would also # work but doesn't reach inside `$lookup`-included sub-docs, # so the post-walker is the defense-in-depth layer. unless resolution.nil? || resolution.master? strip_set = Parse::CLPScope.protected_fields_for( collection_name, perms_for_clp, ) Parse::CLPScope.redact_protected_fields!(results, strip_set) if strip_set.any? end results rescue => e raise_if_timeout!(e, collection_name, max_time_ms) raise end |
.assert_mutations_allowed! ⇒ Object
Run all three gates. Returns nil on success; raises with a message naming the missing gate otherwise.
488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 |
# File 'lib/parse/mongodb.rb', line 488 def assert_mutations_allowed! unless writer_configured? raise WriterNotConfigured, "Index mutations require Parse::MongoDB.configure_writer(uri: ...) " \ "to be called with a write-capable Mongo role URI distinct from the reader." end unless @index_mutations_enabled == true raise MutationsDisabled, "Index mutations are disabled. Set Parse::MongoDB.index_mutations_enabled = true " \ "explicitly (typically in a rake-task initializer, not in a web-process initializer)." end unless mutations_env_enabled? raise MutationsDisabled, "Index mutations require ENV[#{MUTATION_ENV_KEY.inspect}] == '1'. " \ "Set this only in environments where index mutations are intended " \ "(rake tasks, maintenance scripts), never on web/worker dynos." end nil end |
.assert_no_denied_operators!(node, allow_internal_fields: false) ⇒ Object
Walk a filter hash or aggregation pipeline (Hash or Array) and raise DeniedOperator if any nested key matches an entry in PipelineSecurity::DENIED_OPERATORS.
Public for testability and for callers that want to validate input before forwarding to find / aggregate.
2286 2287 2288 2289 2290 2291 |
# File 'lib/parse/mongodb.rb', line 2286 def assert_no_denied_operators!(node, allow_internal_fields: false) Parse::PipelineSecurity.validate_filter!(node, allow_internal_fields: allow_internal_fields) nil rescue Parse::PipelineSecurity::Error => e raise DeniedOperator, e. end |
.available? ⇒ Boolean
Check if direct MongoDB queries are available and enabled
287 288 289 |
# File 'lib/parse/mongodb.rb', line 287 def available? gem_available? && enabled? && uri.present? end |
.collection(name) ⇒ Mongo::Collection
Get a MongoDB collection
393 394 395 |
# File 'lib/parse/mongodb.rb', line 393 def collection(name) client[name] end |
.configure(uri: nil, enabled: true, database: nil, verify_role: true) ⇒ Object
Configure direct MongoDB access.
When ‘uri:` is omitted, the value is resolved from the first environment variable in ENV_URI_KEYS that is set (so `ANALYTICS_DATABASE_URI` wins over `DATABASE_URI`). Raises `ArgumentError` if neither argument nor any env var supplied a URI.
260 261 262 263 264 265 266 267 268 269 270 271 272 273 |
# File 'lib/parse/mongodb.rb', line 260 def configure(uri: nil, enabled: true, database: nil, verify_role: true) require_gem! resolved = uri || resolve_uri_from_env if resolved.nil? || resolved.to_s.empty? raise ArgumentError, "Parse::MongoDB.configure requires a `uri:` argument or one of " \ "#{ENV_URI_KEYS.join(", ")} set in the environment." end @uri = resolved @enabled = enabled @database = database || extract_database_from_uri(resolved) @client = nil # Reset client on reconfigure warn_if_writeable_role! if verify_role && enabled end |
.configure_writer(uri:, enabled: true, verify_role: true) ⇒ Object
Configure the writer connection used for index mutations. Opens a second ‘Mongo::Client` against `uri:`. The connection is validated via `connectionStatus` and rejected fail-closed if its role grants destructive privileges (insert/update/remove/ dropCollection/dropDatabase/etc.). The client is stored privately and is not exposed through any public accessor.
456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 |
# File 'lib/parse/mongodb.rb', line 456 def configure_writer(uri:, enabled: true, verify_role: true) require_gem! raise ArgumentError, "configure_writer requires a uri:" if uri.nil? || uri.to_s.empty? if @uri && @uri.to_s == uri.to_s raise ArgumentError, "configure_writer URI must differ from the reader URI. " \ "The writer is meant for a separately-credentialed Mongo role." end @writer_uri = uri @writer_enabled = enabled @writer_client&.close rescue nil @writer_client = nil if enabled # Eagerly open so a misconfigured URI fails fast at configure time. assert_writer_role_acceptable! if verify_role end end |
.convert_aggregation_document(doc) ⇒ Hash
Convert a raw MongoDB aggregation row, coercing values (BSON ObjectIds, dates, nested documents) but preserving all field names including _id. Unlike convert_document_to_parse, this does NOT rename _id to objectId, because aggregation $group stages reuse _id as the group key (e.g. a pointer string like “Team$abc”) rather than as a Parse object identifier.
1989 1990 1991 1992 1993 1994 |
# File 'lib/parse/mongodb.rb', line 1989 def convert_aggregation_document(doc) return nil unless doc.is_a?(Hash) doc.each_with_object({}) do |(key, value), result| result[key.to_s] = convert_value_to_parse(value) end end |
.convert_document_to_parse(doc, class_name = nil) ⇒ Hash
Convert a MongoDB document to Parse REST API format This transforms MongoDB’s internal field names to Parse’s format:
-
_id -> objectId
-
_created_at -> createdAt
-
_updated_at -> updatedAt
-
_p_fieldName -> fieldName (as pointer)
-
_acl -> ACL (with r/w converted to read/write)
-
Removes other internal fields (_rperm, _wperm, _hashed_password, etc.)
1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 |
# File 'lib/parse/mongodb.rb', line 1906 def convert_document_to_parse(doc, class_name = nil) return nil unless doc.is_a?(Hash) result = {} doc.each do |key, value| key_str = key.to_s case key_str when "_id" # MongoDB _id becomes Parse objectId # Guard against BSON::ObjectId not being defined when mongo gem is not loaded result["objectId"] = if defined?(BSON::ObjectId) && value.is_a?(BSON::ObjectId) value.to_s else value end when "_created_at" # MongoDB _created_at becomes Parse createdAt result["createdAt"] = convert_date_to_parse(value) when "_updated_at" # MongoDB _updated_at becomes Parse updatedAt result["updatedAt"] = convert_date_to_parse(value) when /^_p_(.+)$/ # Pointer fields: _p_author -> author field_name = $1 result[field_name] = convert_pointer_to_parse(value) when "_acl" # Convert MongoDB ACL format (r/w) to Parse format (read/write) result["ACL"] = convert_acl_to_parse(value) when /^_included_(.+)$/ # Included/resolved pointer field from $lookup - convert embedded document # This handles eager loading: _included_artist -> artist (as full object) field_name = $1 if value.is_a?(Hash) # Recursively convert the embedded document to Parse format result[field_name] = convert_document_to_parse(value) elsif value.nil? # Preserve nil for unresolved optional relationships result[field_name] = nil else result[field_name] = value end when /^_include_id_/ # Skip temporary lookup ID fields (used internally for $lookup) next when "_rperm", "_wperm", "_hashed_password", "_email_verify_token", "_perishable_token", "_tombstone", "_failed_login_count", "_account_lockout_expires_at", "_session_token" # Skip internal Parse Server fields (not needed since we use _acl) next when /^_/ # Skip other internal fields starting with underscore next else # Regular fields - recursively convert nested documents result[key_str] = convert_value_to_parse(value) end end # Add className if provided result["className"] = class_name if class_name result end |
.convert_documents_to_parse(docs, class_name = nil) ⇒ Array<Hash>
Convert multiple MongoDB documents to Parse format
1976 1977 1978 |
# File 'lib/parse/mongodb.rb', line 1976 def convert_documents_to_parse(docs, class_name = nil) docs.map { |doc| convert_document_to_parse(doc, class_name) } end |
.create_index(collection_name, keys, name: nil, unique: false, sparse: false, partial_filter: nil, expire_after: nil, allow_system_classes: false) ⇒ Symbol
Create an index on the named collection. Triple-gated; refuses Parse-internal collections unless ‘allow_system_classes: true`. Idempotent: if an index with identical key+options already exists, returns `:exists` without issuing the create.
536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 |
# File 'lib/parse/mongodb.rb', line 536 def create_index(collection_name, keys, name: nil, unique: false, sparse: false, partial_filter: nil, expire_after: nil, allow_system_classes: false) assert_mutations_allowed! assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes) spec_keys = normalize_index_keys(keys) existing = writer_indexes(collection_name, allow_system_classes: allow_system_classes) if index_matches?(existing, spec_keys, name: name, unique: unique, sparse: sparse, partial_filter: partial_filter, expire_after: expire_after) audit_writer_event(:create_index_skipped, collection_name, keys: spec_keys, name: name) return :exists end opts = (name: name, unique: unique, sparse: sparse, partial_filter: partial_filter, expire_after: expire_after) audit_writer_event(:create_index, collection_name, keys: spec_keys, name: name, opts: opts) writer_collection(collection_name).indexes.create_one(spec_keys, **opts) :created end |
.create_search_index(collection_name, name, definition, allow_system_classes: false) ⇒ Symbol
Create an Atlas Search index. Triple-gated like create_index; refuses Parse-internal collections unless ‘allow_system_classes: true`. Idempotent on name: if a search index with the same name already exists, returns `:exists` without issuing the create. The mapping definition of the existing index is NOT diffed — use update_search_index to change a definition.
The build runs ASYNCHRONOUSLY on the Atlas Search node. This method returns as soon as the command is accepted; the index is not queryable until its status transitions to ‘READY`. Poll AtlasSearch::IndexManager.index_ready? to confirm.
663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 |
# File 'lib/parse/mongodb.rb', line 663 def create_search_index(collection_name, name, definition, allow_system_classes: false) assert_mutations_allowed! assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes) validate_search_index_name!(name) validate_search_index_definition!(definition) existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes) if existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s } audit_writer_event(:create_search_index_skipped, collection_name, name: name) return :exists end audit_writer_event(:create_search_index, collection_name, name: name) writer_client.database.command( createSearchIndexes: collection_name.to_s, indexes: [{ name: name.to_s, definition: stringify_keys_deep(definition) }], ) :created end |
.drop_index(collection_name, name, confirm:, allow_system_classes: false) ⇒ Symbol
Drop a named index. Requires the operator-supplied ‘confirm:` string to match `“drop:#collection:#name”` so a stale shell session against the wrong environment can’t accidentally drop something via a rerun.
565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 |
# File 'lib/parse/mongodb.rb', line 565 def drop_index(collection_name, name, confirm:, allow_system_classes: false) assert_mutations_allowed! assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes) expected = "drop:#{collection_name}:#{name}" unless confirm.to_s == expected raise ArgumentError, "drop_index confirmation mismatch. Pass confirm: #{expected.inspect} " \ "to drop #{name.inspect} from #{collection_name.inspect}." end existing = writer_indexes(collection_name, allow_system_classes: allow_system_classes) unless existing.any? { |i| (i["name"] || i[:name]) == name } audit_writer_event(:drop_index_absent, collection_name, name: name) return :absent end audit_writer_event(:drop_index, collection_name, name: name) writer_collection(collection_name).indexes.drop_one(name) :dropped end |
.drop_search_index(collection_name, name, confirm:, allow_system_classes: false) ⇒ Symbol
Drop a named Atlas Search index. Requires the operator-supplied ‘confirm:` string to match `“drop_search:#collection:#name”`. The token deliberately differs from drop_index’s ‘“drop:”` prefix so a token meant for a regular index cannot be replayed against a search index with the same name (and vice versa).
The drop is asynchronous on the Atlas Search node but typically completes quickly; the local cache in AtlasSearch::IndexManager should be invalidated by the caller (the IndexManager wrapper does this).
700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 |
# File 'lib/parse/mongodb.rb', line 700 def drop_search_index(collection_name, name, confirm:, allow_system_classes: false) assert_mutations_allowed! assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes) expected = "drop_search:#{collection_name}:#{name}" unless confirm.to_s == expected raise ArgumentError, "drop_search_index confirmation mismatch. Pass confirm: #{expected.inspect} " \ "to drop search index #{name.inspect} from #{collection_name.inspect}." end existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes) unless existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s } audit_writer_event(:drop_search_index_absent, collection_name, name: name) return :absent end audit_writer_event(:drop_search_index, collection_name, name: name) writer_client.database.command( dropSearchIndex: collection_name.to_s, name: name.to_s, ) :dropped end |
.enabled? ⇒ Boolean
Check if direct queries are enabled
293 294 295 |
# File 'lib/parse/mongodb.rb', line 293 def enabled? @enabled == true end |
.find(collection_name, filter = {}, **options) ⇒ Array<Hash>
Execute a find query directly on MongoDB
1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 |
# File 'lib/parse/mongodb.rb', line 1770 def find(collection_name, filter = {}, **) allow_internal_fields = .delete(:allow_internal_fields) || false assert_no_denied_operators!(filter, allow_internal_fields: allow_internal_fields) max_time_ms = .delete(:max_time_ms) cursor = collection(collection_name).find(filter) explicit_limit = .key?(:limit) applied_default_limit = false if explicit_limit cursor = cursor.limit([:limit]) if [:limit] > 0 else # Apply the hard default BEFORE to_a so we never materialize an # unbounded result set. Fetch one extra row so we can detect when # callers hit the cap and warn them. cursor = cursor.limit(DEFAULT_FIND_LIMIT + 1) applied_default_limit = true end cursor = cursor.skip([:skip]) if [:skip] cursor = cursor.sort([:sort]) if [:sort] cursor = cursor.projection([:projection]) if [:projection] cursor = cursor.max_time_ms(max_time_ms) if max_time_ms results = cursor.to_a if applied_default_limit && results.size > DEFAULT_FIND_LIMIT # Trim the sentinel row and warn — the caller asked for everything # but the result set is larger than the safety cap. results = results.first(DEFAULT_FIND_LIMIT) warn "[Parse::MongoDB.find] on '#{collection_name}' truncated to " \ "#{DEFAULT_FIND_LIMIT} rows (no :limit specified). Pass an " \ "explicit :limit to control the size, or :limit => 0 for " \ "unbounded behavior." end results rescue => e raise_if_timeout!(e, collection_name, max_time_ms) raise end |
.gem_available? ⇒ Boolean
Check if the mongo gem is available
212 213 214 215 216 217 218 219 220 |
# File 'lib/parse/mongodb.rb', line 212 def gem_available? return @gem_available if defined?(@gem_available) @gem_available = begin require "mongo" true rescue LoadError false end end |
.geo_near(collection_name, near:, distance_field: "distance", max_distance: nil, min_distance: nil, unit: :meters, spherical: true, query: nil, include_locs: nil, key: nil, distance_multiplier: nil, limit: nil, additional_stages: [], max_time_ms: nil, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil) ⇒ Array<Hash>
Execute a ‘$geoNear` aggregation against a collection, returning documents sorted by proximity to `near` along with their computed distance. `$geoNear` is the aggregation-pipeline analogue of `$nearSphere`; the headline differences are that it emits the distance value on each returned doc (`distance_field:`) and that downstream pipeline stages can compose with the proximity sort.
A ‘2dsphere` index on the queried geo field is required; the operation errors loudly without one (no silent collection scan). `$geoNear` must be the first stage in the pipeline — Parse::MongoDB places it correctly. The Mongo default 100-document cap was removed in recent server versions, so pass an explicit `limit:` whenever the caller would otherwise drain the entire collection.
1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 |
# File 'lib/parse/mongodb.rb', line 1709 def geo_near(collection_name, near:, distance_field: "distance", max_distance: nil, min_distance: nil, unit: :meters, spherical: true, query: nil, include_locs: nil, key: nil, distance_multiplier: nil, limit: nil, additional_stages: [], max_time_ms: nil, session_token: nil, master: nil, acl_user: nil, acl_role: nil, read_preference: nil) stage = { :$geoNear => { near: geojson_point_for(near), distanceField: distance_field.to_s, spherical: spherical ? true : false, } } max_meters = convert_distance_to_meters(max_distance, unit) if max_distance min_meters = convert_distance_to_meters(min_distance, unit) if min_distance stage[:$geoNear][:maxDistance] = max_meters if max_meters stage[:$geoNear][:minDistance] = min_meters if min_meters stage[:$geoNear][:query] = query if query.is_a?(Hash) && !query.empty? stage[:$geoNear][:includeLocs] = include_locs.to_s if include_locs stage[:$geoNear][:key] = key.to_s if key stage[:$geoNear][:distanceMultiplier] = distance_multiplier if distance_multiplier pipeline = [stage] pipeline << { :$limit => limit } if limit && limit > 0 pipeline.concat(Array(additional_stages)) aggregate(collection_name, pipeline, max_time_ms: max_time_ms, session_token: session_token, master: master, acl_user: acl_user, acl_role: acl_role, read_preference: read_preference) end |
.index_stats(collection_name, master: false) ⇒ Hash{String => Hash}
Per-index usage statistics via the ‘$indexStats` aggregation stage. Returns a Hash keyed by index name with `since:` for each — `ops` is the number of times the index has been accessed since the last MongoDB restart, `since` is the timestamp of that restart (i.e. the start of the counting window). Empty Hash on access error so callers (e.g. `Model.describe(:indexes, network: true, usage: true)`) degrade gracefully when the authenticated role lacks `clusterMonitor` (the minimum privilege `$indexStats` requires).
Admin-only. This is a metadata-disclosure surface (which indexes are hot fingerprints which classes hold interesting data) and so requires explicit ‘master: true` to invoke. The previous behavior hard-coded `master: true` internally, which was a copy-paste-lethal pattern for any future row-returning path. Callers without master scope raise `ArgumentError` internally; that error is caught by the method’s own degrade-to-empty rescue so existing best-effort callers (‘Parse::Model.describe(:indexes, usage: true)`) continue to surface `usage_available: false` instead of blowing up — but the `ArgumentError` is the loud signal for anyone introducing a new caller that forgets the opt-in. Direct callers that disable the rescue (test mocks, callers wrapping with their own error handling) will see the `ArgumentError` propagate.
1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 |
# File 'lib/parse/mongodb.rb', line 1870 def index_stats(collection_name, master: false) unless master == true raise ArgumentError, "Parse::MongoDB.index_stats is admin-only and requires `master: true`. " \ "$indexStats discloses cluster metadata; pass `master: true` to confirm " \ "the caller is authorized. Callers without master scope (e.g. agent " \ "tools, request handlers) must not invoke this method." end results = aggregate(collection_name, [{ "$indexStats" => {} }], master: true) results.each_with_object({}) do |row, h| name = row["name"] || row[:name] next unless name accesses = row["accesses"] || row[:accesses] || {} h[name] = { ops: (accesses["ops"] || accesses[:ops]).to_i, since: accesses["since"] || accesses[:since], } end rescue StandardError # Lack of clusterMonitor / Atlas BI restriction / NamespaceNotFound # all surface here — `usage:` is best-effort by design. {} end |
.indexes(collection_name) ⇒ Array<Hash>
List regular MongoDB indexes for a collection. Hits the system catalog via the driver’s ‘indexes.list` and returns the raw definitions — distinct from list_search_indexes, which only enumerates Atlas Search indexes. Operator-facing introspection used by `Parse::Core::Describe`.
1830 1831 1832 1833 1834 1835 1836 1837 1838 |
# File 'lib/parse/mongodb.rb', line 1830 def indexes(collection_name) collection(collection_name).indexes.to_a rescue StandardError => e # `listIndexes` raises NamespaceNotFound on collections that # haven't been created yet — treat as "no indexes" so describe # and plan paths degrade gracefully on empty databases. return [] if mongo_namespace_not_found?(e) raise end |
.list_search_indexes(collection_name) ⇒ Array<Hash>
Requires MongoDB Atlas or local Atlas deployment
List Atlas Search indexes for a collection Uses the $listSearchIndexes aggregation stage.
1815 1816 1817 |
# File 'lib/parse/mongodb.rb', line 1815 def list_search_indexes(collection_name) aggregate(collection_name, [{ "$listSearchIndexes" => {} }]) end |
.mutations_env_enabled? ⇒ Boolean
Returns true iff ‘ENV == “1”`.
481 482 483 |
# File 'lib/parse/mongodb.rb', line 481 def mutations_env_enabled? ENV[MUTATION_ENV_KEY].to_s == "1" end |
.normalize_read_preference(value) ⇒ Symbol?
Normalize a Parse-style read-preference value into the Mongo Ruby driver’s ‘:mode` symbol. Accepts `nil` (returns `nil`), the five documented Parse strings (`PRIMARY`, `PRIMARY_PREFERRED`, `SECONDARY`, `SECONDARY_PREFERRED`, `NEAREST`) in any case with hyphens or underscores, and the equivalent symbol form. Unknown values produce a warning and return `nil` so the operation falls back to the client default rather than failing.
406 407 408 409 410 411 412 413 414 415 |
# File 'lib/parse/mongodb.rb', line 406 def normalize_read_preference(value) return nil if value.nil? token = value.to_s.tr("-", "_").downcase valid = %w[primary primary_preferred secondary secondary_preferred nearest].freeze unless valid.include?(token) warn "[Parse::MongoDB] Invalid read_preference #{value.inspect}; ignoring." return nil end token.to_sym end |
.read_only? ⇒ Boolean?
Probe whether the authenticated user on the configured URI has any write privileges. Issues the ‘connectionStatus` command with `showPrivileges: true` — a read-only call that returns the user’s role-derived privilege list.
Return values:
-
‘true` — user’s privileges include no entries from WRITE_ACTIONS on the configured database. The role is observable read-only.
-
‘false` — at least one write action was found.
-
‘nil` — couldn’t determine (no privilege list returned, command not supported, network failure). Treat as “unknown” — don’t trust either answer.
Caveats:
-
This is a ROLE check, not a transport check. A ‘readPreference= secondary` URI with a write-capable user is still write-capable; the driver routes writes to primary regardless of read preference.
-
Some MongoDB configurations restrict the user’s visibility into their own privileges; an empty privilege list returns ‘nil`, not `true`.
-
Atlas Data Federation, BI Connector, and other non-standard endpoints may respond differently or refuse the command — also ‘nil`.
332 333 334 335 336 337 338 339 340 341 342 343 344 |
# File 'lib/parse/mongodb.rb', line 332 def read_only? return nil unless available? result = client.database.command(connectionStatus: 1, showPrivileges: true).first privileges = result && result.dig("authInfo", "authenticatedUserPrivileges") return nil if privileges.nil? || privileges.empty? write_set = WRITE_ACTIONS.to_set has_write = privileges.any? do |priv| Array(priv["actions"]).any? { |a| write_set.include?(a.to_s) } end !has_write rescue StandardError nil end |
.require_gem! ⇒ Object
Ensure mongo gem is loaded, raise error if not
224 225 226 227 228 229 |
# File 'lib/parse/mongodb.rb', line 224 def require_gem! return if gem_available? raise GemNotAvailable, "The 'mongo' gem is required for direct MongoDB queries. " \ "Add 'gem \"mongo\"' to your Gemfile and run 'bundle install'." end |
.reset! ⇒ Object
Reset the client connection (useful for testing)
381 382 383 384 385 386 387 388 |
# File 'lib/parse/mongodb.rb', line 381 def reset! @client&.close rescue nil @client = nil @enabled = false @uri = nil @database = nil reset_writer! end |
.reset_writer! ⇒ Object
Reset the writer connection and clear gate state. Called from reset!; can be invoked directly for granular teardown.
510 511 512 513 514 515 |
# File 'lib/parse/mongodb.rb', line 510 def reset_writer! @writer_client&.close rescue nil @writer_client = nil @writer_uri = nil @writer_enabled = false end |
.resolve_uri_from_env ⇒ String?
Returns the first env-var URI found, in ENV_URI_KEYS priority order, or nil if none is set.
277 278 279 280 281 282 283 |
# File 'lib/parse/mongodb.rb', line 277 def resolve_uri_from_env ENV_URI_KEYS.each do |key| value = ENV[key] return value if value && !value.empty? end nil end |
.role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil) ⇒ Set<String>?
Resolve every role name a user inherits via a single ‘$graphLookup` aggregation against the Parse role-membership and role-inheritance join tables.
This is the mongo-direct fast path that Role.all_for_user falls into when an explicit authorization scope is provided. The pipeline shape is hardcoded; only ‘user_id` and `max_depth` are interpolated, and both are validated against ROLE_GRAPH_ID_RE / ROLE_GRAPH_MAX_DEPTH.
The call bypasses aggregate on purpose: that entry point injects an ‘_rperm` `$match` and rewrites `$lookup` / `$graphLookup` stages with the same predicate, which would filter every `_Join:*:_Role` row to zero (those join collections have no `_rperm` column). PipelineSecurity.validate_filter! still runs against the constructed pipeline as belt-and-braces protection against a future regression that interpolates a caller value into a denied operator.
If ‘_Join:roles:_Role` doesn’t exist (the app uses flat roles without inheritance), MongoDB treats the missing collection as empty and ‘$graphLookup` returns no parents — the result collapses to direct memberships only, matching the Parse-Server-backed walk.
## Authorization contract
The helper requires an EXPLICIT per-call authorization:
* `master: true` — explicit master-mode opt-in. Bypasses
`_Role` CLP. Use for admin tooling, analytics jobs, and
any code path that legitimately needs to read role graphs
across users.
* `as: <User|Pointer>` — caller scope. The supplied user must
be permitted to `find` on `_Role` under the cached CLP, or
{Parse::CLPScope::Denied} is raised. `_Role`'s default CLP
is master-only, so this path will fail closed unless the
operator has explicitly opened `_Role` CLP for the user.
Passing neither raises ‘ArgumentError`. The previous behavior (gated only on the process-level `master_key_available?` boolean — a check on the SDK’s boot config, not the caller’s authority) is removed — it provided no per-call authorization.
## Return-value contract
-
‘Set<String>` on success (possibly empty if the user has no direct memberships).
-
‘nil` when the fast path is unavailable (mongo gem missing, available? false). Callers fall back to the Parse-Server N+1 walk.
-
Raises ExecutionTimeout on Mongo timeout (attack-signal — do not silently fall back), ‘ArgumentError` on input-validation failure or missing authorization, and propagates other `Mongo::Error` subclasses that aren’t recognized as benign availability errors.
1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 |
# File 'lib/parse/mongodb.rb', line 1048 def role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil) (:role_names_for_user, master: master, as: as) validate_role_graph_id!(user_id, "user_id") depth = validate_role_graph_depth!(max_depth) return Set.new if depth <= 0 return nil unless available? graph_depth = depth - 1 pipeline = build_user_role_names_pipeline(user_id, graph_depth) Parse::PipelineSecurity.validate_filter!( pipeline, allow_internal_fields: true, ) result_set = nil ActiveSupport::Notifications.instrument( "parse.mongodb.role_graph", direction: :forward, target_id: user_id, depth: depth, ) do |payload| docs = collection("_Join:users:_Role").aggregate( pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS, ).to_a names = Array(docs.first && docs.first["names"]) result_set = Set.new( names.reject { |n| n.nil? || n.to_s.empty? }.map(&:to_s), ) payload[:result_count] = result_set.size end result_set rescue NotEnabled, GemNotAvailable nil rescue StandardError => e if defined?(::Mongo::Error::OperationFailure) && e.is_a?(::Mongo::Error::OperationFailure) raise_if_timeout!(e, "_Join:users:_Role", ROLE_GRAPH_MAX_TIME_MS) end raise end |
.to_mongodb_date(value) ⇒ Time?
Convert a date value to a UTC Time object suitable for MongoDB queries. MongoDB stores all dates in UTC, so this helper ensures consistent date handling when building aggregation pipelines or direct queries.
2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 |
# File 'lib/parse/mongodb.rb', line 2027 def to_mongodb_date(value) return nil if value.nil? case value when ::Time value.utc when ::DateTime value.to_time.utc when ::Date # Convert Date to midnight UTC ::Time.utc(value.year, value.month, value.day) when ::String # Parse string dates - try ISO 8601 first, then Date.parse begin if value =~ /T/ # ISO 8601 with time component ::Time.parse(value).utc else # Date-only string, convert to midnight UTC date = ::Date.parse(value) ::Time.utc(date.year, date.month, date.day) end rescue ::ArgumentError => e raise ::ArgumentError, "Cannot parse '#{value}' as a date: #{e.}" end when ::Integer # Assume Unix timestamp ::Time.at(value).utc else raise ::ArgumentError, "Cannot convert #{value.class} to MongoDB date. " \ "Expected Date, Time, DateTime, String, or Integer." end end |
.update_search_index(collection_name, name, definition, allow_system_classes: false) ⇒ Symbol
Replace the definition of an existing Atlas Search index. The rebuild runs asynchronously on the Atlas Search node; the new mapping is not live until the index’s status transitions back to ‘READY`. Poll AtlasSearch::IndexManager.index_ready? to confirm.
Raises ‘ArgumentError` if no search index with that name exists — use create_search_index for new indexes. The mapping diff is not computed; the command is issued unconditionally for existing indexes (Atlas itself handles “definition unchanged” cases gracefully).
740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 |
# File 'lib/parse/mongodb.rb', line 740 def update_search_index(collection_name, name, definition, allow_system_classes: false) assert_mutations_allowed! assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes) validate_search_index_name!(name) validate_search_index_definition!(definition) existing = writer_search_indexes(collection_name, allow_system_classes: allow_system_classes) unless existing.any? { |i| (i["name"] || i[:name]).to_s == name.to_s } audit_writer_event(:update_search_index_absent, collection_name, name: name) raise ArgumentError, "update_search_index: no Atlas Search index named #{name.inspect} " \ "on collection #{collection_name.inspect}. Use create_search_index to create one." end audit_writer_event(:update_search_index, collection_name, name: name) writer_client.database.command( updateSearchIndex: collection_name.to_s, name: name.to_s, definition: stringify_keys_deep(definition), ) :updated end |
.users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil) ⇒ Set<String>?
Resolve every ‘_User.objectId` whose effective role set includes `role_id` — i.e., direct members of `role_id` PLUS direct members of any descendant role in `role_id`’s inheritance subtree.
Walks DOWN the inheritance tree via ‘$graphLookup` against `_Join:roles:_Role` (parent → children → grandchildren), then joins to `_Join:users:_Role` to pluck member ids, and finally filters out tombstoned `_User` rows so the fast path matches the soft-delete semantics the Parse-Server-backed path gets for free via REST CLP enforcement.
When called with a scoped ‘as:` argument (not master mode), the `_User` `$lookup` sub-pipeline is augmented with an `_rperm` `$match` so the joined `_User` rows are filtered to ones the scope can read. Without this, the join leaks `_User._id` regardless of caller authorization.
Same authorization contract, return-value contract, and error-policy as role_names_for_user.
1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 |
# File 'lib/parse/mongodb.rb', line 1122 def users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil) resolution = ( :users_in_role_subtree, master: master, as: as, ) validate_role_graph_id!(role_id, "role_id") depth = validate_role_graph_depth!(max_depth) return Set.new if depth <= 0 return nil unless available? graph_depth = depth - 1 # Caller-scope path injects the resolved _rperm allow-set into # the _User sub-pipeline so the join honors row-level ACL. # Master mode leaves the sub-pipeline unscoped — the explicit # `master: true` is the operator's intent. rperm_allow = nil unless resolution.nil? || resolution.master? rperm_allow = resolution. end pipeline = build_role_subtree_users_pipeline( role_id, graph_depth, rperm_allow: rperm_allow, ) Parse::PipelineSecurity.validate_filter!( pipeline, allow_internal_fields: true, ) result_set = nil ActiveSupport::Notifications.instrument( "parse.mongodb.role_graph", direction: :reverse, target_id: role_id, depth: depth, ) do |payload| docs = collection("_Join:roles:_Role").aggregate( pipeline, max_time_ms: ROLE_GRAPH_MAX_TIME_MS, ).to_a ids = Array(docs.first && docs.first["user_ids"]) result_set = Set.new( ids.reject { |i| i.nil? || i.to_s.empty? }.map(&:to_s), ) payload[:result_count] = result_set.size end result_set rescue NotEnabled, GemNotAvailable nil rescue StandardError => e if defined?(::Mongo::Error::OperationFailure) && e.is_a?(::Mongo::Error::OperationFailure) raise_if_timeout!(e, "_Join:roles:_Role", ROLE_GRAPH_MAX_TIME_MS) end raise end |
.warn_if_writeable_role! ⇒ Object
This method is part of a private API. You should avoid using this method if possible, as it may be removed or be changed in the future.
Emit a warning when read_only? reports a writeable role. Called from configure when ‘verify_role: true`. Silent on `true` (correctly read-only) and on `nil` (couldn’t determine — too noisy to surface in normal operation).
351 352 353 354 355 356 357 358 359 360 361 362 |
# File 'lib/parse/mongodb.rb', line 351 def warn_if_writeable_role! case read_only? when false warn "[Parse::MongoDB] WARNING: the URI configured for direct " \ "queries authenticates a user with write privileges. The " \ "direct path is read-only by design; using a read-only " \ "role bounds the blast radius if caller code touches " \ "`Parse::MongoDB.client` directly. See " \ "docs/mongodb_direct_guide.md for routing direct reads at " \ "an analytics replica." end end |
.writer_configured? ⇒ Boolean
Returns true when configure_writer has been called with ‘enabled: true` and the connection is reachable.
476 477 478 |
# File 'lib/parse/mongodb.rb', line 476 def writer_configured? !@writer_uri.nil? && @writer_enabled == true end |
.writer_indexes(collection_name, allow_system_classes: false) ⇒ Array<Hash>
List indexes on a collection via the WRITER connection. Distinct from indexes which uses the reader. Used by create_index for the idempotency check so the existence read is performed on the same connection that will issue the create.
591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 |
# File 'lib/parse/mongodb.rb', line 591 def writer_indexes(collection_name, allow_system_classes: false) assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes) # NOTE: listing does not require the mutation gate — operators # can inspect what's there even when mutations are disabled, # which is useful for `parse:mongo:indexes:plan` dry-runs that # don't intend to mutate. unless writer_configured? raise WriterNotConfigured, "writer_indexes requires configure_writer to have been called." end begin writer_collection(collection_name).indexes.to_a rescue StandardError => e # Mongo raises NamespaceNotFound (code 26) when the collection # has not been created yet — listing indexes on a non-existent # collection is "no indexes" from the SDK's perspective. Match # by code AND by message substring because the driver's exact # class path varies across versions. return [] if mongo_namespace_not_found?(e) raise end end |
.writer_search_indexes(collection_name, allow_system_classes: false) ⇒ Array<Hash>
List Atlas Search indexes via the WRITER connection. Distinct from list_search_indexes which uses the reader’s aggregate path. Used by the search-index mutation primitives below for the existence check so the read is performed on the same connection that will issue the mutation. Returns ‘[]` for collections that do not yet exist.
625 626 627 628 629 630 631 632 633 634 635 636 637 638 |
# File 'lib/parse/mongodb.rb', line 625 def writer_search_indexes(collection_name, allow_system_classes: false) assert_collection_allowed!(collection_name, allow_system_classes: allow_system_classes) unless writer_configured? raise WriterNotConfigured, "writer_search_indexes requires configure_writer to have been called." end begin writer_collection(collection_name) .aggregate([{ "$listSearchIndexes" => {} }]).to_a rescue StandardError => e return [] if mongo_namespace_not_found?(e) raise end end |