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
_p_prefix (e.g.,_p_author,_p_album) - Built-in dates: Use
_created_atand_updated_at - Field references: Use
$fieldNamesyntax (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: ClientMismatch, ConnectionError, DeniedOperator, ExecutionTimeout, ForbiddenCollection, GemNotAvailable, MutationsDisabled, NotEnabled, WriterNotConfigured, WriterRoleTooPermissive
Constant Summary collapse
- DEFAULT_FIND_LIMIT =
Threshold above which
Parse::MongoDB.findemits a deprecation warning when called without an explicit:limitoption. A future major release will enforce this as a hard default limit. Callers should pass an explicit:limit(including:limit => 0for 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_URIis listed first so deployments can point direct-read traffic at a dedicated analytics replica without disturbing the primaryDATABASE_URIthat Parse Server uses for writes.DATABASE_URIis 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
-
.bound_app_scope ⇒ String?
readonly
The application scope this global connection is bound to, captured at MongoDB.configure time.
-
.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, client: nil, read_preference: nil, hint: nil) ⇒ Array<Hash>
Execute an aggregation pipeline directly on MongoDB.
-
.app_scope_for(client) ⇒ String?
Identify a Parse application by BOTH its application id and its server url.
-
.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, authorizing_client: nil) ⇒ 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, client: nil, read_preference: nil) ⇒ Array<Hash>
Execute a
$geoNearaggregation against a collection, returning documents sorted by proximity tonearalong with their computed distance. -
.geo_near_stage_key(stage) ⇒ Symbol, ...
The
$geoNearkey (symbol or string form) ifstageis a$geoNearstage, else nil. -
.index_stats(collection_name, master: false) ⇒ Hash{String => Hash}
Per-index usage statistics via the
$indexStatsaggregation 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[MUTATION_ENV_KEY] == "1". -
.normalize_read_preference(value) ⇒ Symbol?
Normalize a Parse-style read-preference value into the Mongo Ruby driver's
:modesymbol. -
.prepend_or_fold_acl_match(pipeline, acl_stage) ⇒ Array<Hash>
Inject the scoped ACL
$matchat the front of a pipeline — UNLESS the first stage is$geoNear, which MongoDB requires to be pipeline stage 0. -
.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, client: nil) ⇒ Set<String>?
Resolve every role name a user inherits via a single
$graphLookupaggregation against the Parse role-subscription 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, client: nil) ⇒ Set<String>?
Resolve every
_User.objectIdwhose effective role set includesrole_id— i.e., direct members ofrole_idPLUS direct members of any descendant role inrole_id's inheritance subtree. -
.verify_client!(client) ⇒ Object
Refuse a mongo-direct query issued by a client that is not the one this connection was configured for.
-
.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: trueand 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
.bound_app_scope ⇒ String? (readonly)
Returns the application scope this global connection is bound to, captured at configure time. See app_scope_for.
289 290 291 |
# File 'lib/parse/mongodb.rb', line 289 def bound_app_scope @bound_app_scope end |
.client ⇒ Mongo::Client (readonly)
Get or create the MongoDB client
213 214 215 |
# File 'lib/parse/mongodb.rb', line 213 def client @client end |
.database ⇒ String
MongoDB database name (extracted from URI or set manually).
208 209 210 |
# File 'lib/parse/mongodb.rb', line 208 def database @database end |
.enabled ⇒ Boolean
Feature flag to enable/disable direct MongoDB queries.
198 199 200 |
# File 'lib/parse/mongodb.rb', line 198 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).
642 643 644 |
# File 'lib/parse/mongodb.rb', line 642 def index_mutations_enabled @index_mutations_enabled end |
.uri ⇒ String
MongoDB connection URI.
203 204 205 |
# File 'lib/parse/mongodb.rb', line 203 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, client: nil, read_preference: nil, hint: nil) ⇒ Array<Hash>
Execute an aggregation pipeline directly on MongoDB
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 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 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 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 |
# File 'lib/parse/mongodb.rb', line 1722 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, client: nil, read_preference: nil, hint: nil) # AS::N envelope. Payload is intentionally metadata-only — # `stage_count`, `stage_types`, `collection`, `scope`, # `result_count`, `max_time_ms`, `read_preference`. Pipeline # bodies are NOT included: they routinely embed user-id # strings, tenant identifiers, search terms, and other PII # that has no business in a log line or an APM span. The # `parse.mongodb.role_graph` notification (emitted lower in # this module) nests as a child event when role expansion # runs inside the surrounding aggregate. `result_count` and # `scope` are seeded nil so subscribers see a stable key set # even on the raise path (where the block exits before either # is written). instrument_payload = { collection: collection_name, stage_count: pipeline.is_a?(Array) ? pipeline.size : 0, stage_types: __extract_stage_types(pipeline), max_time_ms: max_time_ms, read_preference: read_preference&.to_s, scope: nil, result_count: nil, } ActiveSupport::Notifications.instrument("parse.mongodb.aggregate", instrument_payload) do |payload| # 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, # The client whose authorization context resolves this call. Nil # falls back to Parse.client at the ACLScope boundary. It is carried # onto the Resolution so verify_client! below can compare it against # the application this process-global connection is bound to. client: client, }.compact resolution = Parse::ACLScope.resolve!(auth_kwargs, method_name: :aggregate) # The resolution above is client-scoped; the connection below is not. # Refuse the combination rather than reading another application's # database with this application's permission strings. verify_client!(resolution.client) payload[:scope] = __scope_label(resolution) # 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 = prepend_or_fold_acl_match(pipeline, acl_stage) 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 # Forced index hint (Query#hint). Mirrors Parse Server's REST `hint` # on the mongo-direct path so a bad plan diagnosed with `explain` can # be corrected here too. Accepts an index name (String) or a key # pattern (Hash). agg_opts[:hint] = hint unless hint.nil? # The SAME client this call already verified against. Passing nothing # here made the collection lookup an unidentified caller, so once a # second application had been observed a perfectly legitimate # aggregate for the bound application was refused. coll = collection(collection_name, authorizing_client: Parse::ACLScope.client_of(resolution)) 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? # Process-level floor: recursively strip Parse-internal credential # columns (_hashed_password, _session_token, _auth_data_*, _rperm, # ...) from every row AND every embedded sub-document. The # protectedFields strip above is keyed on the OUTER class, and the # ACL sub-doc walk only DROPS ACL-failing sub-docs — neither covers # a foreign class (e.g. _User / _Session) pulled in via $lookup / # $graphLookup / $unionWith under an arbitrary alias. Runs last, for # scoped (non-master) callers only; master is unredacted by design. results.each do |row| Parse::PipelineSecurity.redact_internal_fields_deep!(row) end end payload[:result_count] = results.size results end rescue => e raise_if_timeout!(e, collection_name, max_time_ms) raise end |
.app_scope_for(client) ⇒ String?
Identify a Parse application by BOTH its application id and its server url.
The application id alone is not enough. Parse application ids are not globally unique: the same id is routinely reused across a staging and a production deployment of the same app, which is exactly the pair most likely to be configured in one developer process. Comparing ids only would let a staging client authorize a read of the production database and call it a match, which is the failure this guard exists to prevent rather than a case it may ignore.
Mirrors Cache::Keyspace's app scope, which derives the same pair for the same reason.
308 309 310 311 312 313 314 315 |
# File 'lib/parse/mongodb.rb', line 308 def app_scope_for(client) return nil if client.nil? return nil unless client.respond_to?(:application_id) app_id = client.application_id return nil if app_id.nil? || app_id.to_s.empty? server = client.respond_to?(:server_url) ? client.server_url.to_s : "" "#{app_id}\u0000#{server}" end |
.assert_mutations_allowed! ⇒ Object
Run all three gates. Returns nil on success; raises with a message naming the missing gate otherwise.
697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 |
# File 'lib/parse/mongodb.rb', line 697 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.
2674 2675 2676 2677 2678 2679 |
# File 'lib/parse/mongodb.rb', line 2674 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
476 477 478 |
# File 'lib/parse/mongodb.rb', line 476 def available? gem_available? && enabled? && uri.present? end |
.collection(name, authorizing_client: nil) ⇒ Mongo::Collection
Get a MongoDB collection
585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 |
# File 'lib/parse/mongodb.rb', line 585 def collection(name, authorizing_client: nil) # The last chokepoint before the database. {.aggregate} verifies the # binding too, but not every scoped read goes through it: Atlas # Search runs its own `$search` pipelines and hybrid vector search # runs its own, both reaching the driver through here. # # `authorizing_client:` must be the client that AUTHORIZED the read. # An earlier version compared `Parse.client` unconditionally, which # was worse than no check at all: a caller that resolved a token # against client B and then asked for a collection had its default # client A compared against A's own binding, so the guard reported # success on exactly the case it exists to catch. # NOT `authorizing_client || default_client_or_nil`. Substituting the # default presented a forgotten `client:` as the default client, so # it passed against a default-bound database and the fail-closed # branch could never fire. A missing client is an unidentified # caller, and {.verify_client!} decides what that means. verify_client!() 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.
265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 |
# File 'lib/parse/mongodb.rb', line 265 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 # Bind this connection to whichever Parse application is configured # now. See {.verify_client!} for why. BINDING_MUTEX.synchronize do @bound_app_scope = current_app_scope @observed_app_scopes = nil note_observed_scope_unlocked(@bound_app_scope) end 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.
665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 |
# File 'lib/parse/mongodb.rb', line 665 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 "Workspace$abc") rather than as a
Parse object identifier.
2347 2348 2349 2350 2351 2352 |
# File 'lib/parse/mongodb.rb', line 2347 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.)
2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 |
# File 'lib/parse/mongodb.rb', line 2264 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
2334 2335 2336 |
# File 'lib/parse/mongodb.rb', line 2334 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.
745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 |
# File 'lib/parse/mongodb.rb', line 745 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.
872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 |
# File 'lib/parse/mongodb.rb', line 872 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.
774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 |
# File 'lib/parse/mongodb.rb', line 774 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).
909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 |
# File 'lib/parse/mongodb.rb', line 909 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
482 483 484 |
# File 'lib/parse/mongodb.rb', line 482 def enabled? @enabled == true end |
.find(collection_name, filter = {}, **options) ⇒ Array<Hash>
Execute a find query directly on MongoDB
2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 |
# File 'lib/parse/mongodb.rb', line 2100 def find(collection_name, filter = {}, **) max_time_ms = .delete(:max_time_ms) # Consumed like the other auth kwargs so it never reaches the driver. find_client = .delete(:client) # Metadata-only AS::N payload: collection, presence-of-filter # (NOT body), projection keys (column names, not values), limit, # max_time_ms, result_count. Filter / projection bodies are # excluded because they routinely embed user-id strings, # tenant IDs, and other PII that has no business in a log line # or a span. The `find` payload deliberately has no `:scope` # field — `Parse::MongoDB.find` takes no ACL kwargs, so there # is no resolution to label. Shared subscribers that handle # both event names must treat `payload[:scope]` as optional. # `result_count` is seeded nil so subscribers see a stable key # set even on the raise path. projection_keys = if [:projection].is_a?(Hash) [:projection].keys.map(&:to_s) end instrument_payload = { collection: collection_name, has_filter: filter.is_a?(Hash) && !filter.empty?, projection_keys: projection_keys, limit: [:limit], max_time_ms: max_time_ms, result_count: nil, } ActiveSupport::Notifications.instrument("parse.mongodb.find", instrument_payload) do |payload| allow_internal_fields = .delete(:allow_internal_fields) || false assert_no_denied_operators!(filter, allow_internal_fields: allow_internal_fields) cursor = collection(collection_name, authorizing_client: find_client).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.hint([:hint]) unless [:hint].nil? 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 payload[:result_count] = results.size results end rescue => e raise_if_timeout!(e, collection_name, max_time_ms) raise end |
.gem_available? ⇒ Boolean
Check if the mongo gem is available
217 218 219 220 221 222 223 224 225 |
# File 'lib/parse/mongodb.rb', line 217 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, client: 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.
2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 |
# File 'lib/parse/mongodb.rb', line 2037 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, client: 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, client: client, read_preference: read_preference) end |
.geo_near_stage_key(stage) ⇒ Symbol, ...
Returns the $geoNear key (symbol or string
form) if stage is a $geoNear stage, else nil.
1954 1955 1956 1957 1958 1959 |
# File 'lib/parse/mongodb.rb', line 1954 def geo_near_stage_key(stage) return nil unless stage.is_a?(Hash) return :$geoNear if stage.key?(:$geoNear) return "$geoNear" if stage.key?("$geoNear") nil 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 {ops:, 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.
2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 |
# File 'lib/parse/mongodb.rb', line 2228 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.
2188 2189 2190 2191 2192 2193 2194 2195 2196 |
# File 'lib/parse/mongodb.rb', line 2188 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.
2173 2174 2175 |
# File 'lib/parse/mongodb.rb', line 2173 def list_search_indexes(collection_name) aggregate(collection_name, [{ "$listSearchIndexes" => {} }]) end |
.mutations_env_enabled? ⇒ Boolean
Returns true iff ENV[MUTATION_ENV_KEY] == "1".
690 691 692 |
# File 'lib/parse/mongodb.rb', line 690 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.
615 616 617 618 619 620 621 622 623 624 |
# File 'lib/parse/mongodb.rb', line 615 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 |
.prepend_or_fold_acl_match(pipeline, acl_stage) ⇒ Array<Hash>
Inject the scoped ACL $match at the front of a pipeline — UNLESS
the first stage is $geoNear, which MongoDB requires to be
pipeline stage 0. In that case fold the ACL predicate into
$geoNear.query (a candidate-document pre-filter, semantically
equivalent to a leading $match) so the stage-0 invariant holds
and the scoped geo query still enforces _rperm.
A scoped geo_near previously failed CLOSED here: the prepended
$match pushed $geoNear off stage 0 and MongoDB rejected the
whole pipeline. Post-fetch redaction (protectedFields / sub-doc /
internal-field) runs regardless, so folding loses no enforcement.
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 |
# File 'lib/parse/mongodb.rb', line 1925 def prepend_or_fold_acl_match(pipeline, acl_stage) geo_key = geo_near_stage_key(pipeline.first) return [acl_stage] + pipeline unless geo_key match_pred = acl_stage["$match"] || acl_stage[:$match] geo = pipeline.first[geo_key].dup # Match the stage's own key style: reuse an existing `query` key of # either type, else follow the `$geoNear` key's type (string stage # → "query", symbol stage → :query). The Mongo driver normalizes # either way, but keeping one style avoids a duplicate query key. q_key = if geo.key?("query") then "query" elsif geo.key?(:query) then :query elsif geo_key.is_a?(String) then "query" else :query end existing = geo[q_key] geo[q_key] = if existing.is_a?(Hash) && !existing.empty? # `existing` is still the caller's own `$geoNear.query` hash # (the outer `.dup` above is shallow). Embed a copy, not the # original, so the folded pipeline and the caller's pipeline # don't share a mutable hash — honoring the "caller stages are # not mutated" contract in both directions. { "$and" => [existing.dup, match_pred] } else match_pred end new_first = pipeline.first.dup new_first[geo_key] = geo [new_first] + pipeline[1..] 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= secondaryURI 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, nottrue. - Atlas Data Federation, BI Connector, and other non-standard
endpoints may respond differently or refuse the command — also
nil.
521 522 523 524 525 526 527 528 529 530 531 532 533 |
# File 'lib/parse/mongodb.rb', line 521 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
229 230 231 232 233 234 |
# File 'lib/parse/mongodb.rb', line 229 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)
570 571 572 573 574 575 576 577 578 579 580 |
# File 'lib/parse/mongodb.rb', line 570 def reset! @client&.close rescue nil @client = nil @enabled = false @uri = nil @bound_app_scope = nil @observed_app_scopes = nil @database = nil remove_instance_variable(:@gem_available) if defined?(@gem_available) reset_writer! end |
.reset_writer! ⇒ Object
Reset the writer connection and clear gate state. Called from reset!; can be invoked directly for granular teardown.
719 720 721 722 723 724 |
# File 'lib/parse/mongodb.rb', line 719 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.
466 467 468 469 470 471 472 |
# File 'lib/parse/mongodb.rb', line 466 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, client: nil) ⇒ Set<String>?
Resolve every role name a user inherits via a single
$graphLookup aggregation against the Parse role-subscription 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 subscriptions 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 subscriptions).nilwhen 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),
ArgumentErroron input-validation failure or missing authorization, and propagates otherMongo::Errorsubclasses that aren't recognized as benign availability errors.
1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 |
# File 'lib/parse/mongodb.rb', line 1264 def role_names_for_user(user_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil, client: nil) # Public entry point: resolve an omitted client to the default ONCE, # here, and use the same value for the authorization and for the # collection below. Passing the raw `client:` down meant an ordinary # default-client call reached the sink as unidentified and was # rejected once a second application had been observed. client ||= default_client_or_nil (:role_names_for_user, master: master, as: as, client: client) 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| # The role graph is an authorization input, so this read is # checked against the client that asked for it like any other. docs = collection("_Join:users:_Role", authorizing_client: client).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.
2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 |
# File 'lib/parse/mongodb.rb', line 2385 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).
949 950 951 952 953 954 955 956 957 958 959 960 961 962 963 964 965 966 967 968 |
# File 'lib/parse/mongodb.rb', line 949 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, client: 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.
1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 |
# File 'lib/parse/mongodb.rb', line 1347 def users_in_role_subtree(role_id, max_depth: ROLE_GRAPH_DEFAULT_DEPTH, master: false, as: nil, client: nil) # See {.role_names_for_user}: resolve the omission once at the entry # point rather than letting nil travel to the collection lookup. client ||= default_client_or_nil resolution = ( :users_in_role_subtree, master: master, as: as, client: client, ) 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", authorizing_client: client).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 |
.verify_client!(client) ⇒ Object
Refuse a mongo-direct query issued by a client that is not the one this connection was configured for.
The connection is process-global: one URI, one database, one driver
client, chosen by whoever called configure. Authorization, since
5.7, is per-client: client.authorization resolves a session token
against that client's own Parse application. Those two facts are safe
in isolation and dangerous together. A secondary client would resolve
its token correctly, against its own application, produce a correct
_rperm allow-set for a user of that application, and then run the
resulting pipeline against the OTHER application's database. The user
ids and role names would be matched against rows they have nothing to
do with, and any collision is a cross-application read.
Failing closed is the only safe answer, because the alternative is silent and looks like a working query. The connection becomes client-owned in 6.0, at which point this guard is unnecessary.
A caller of nil is UNIDENTIFIED, and is never silently upgraded to
the default client. Substituting a default there made the
fail-closed branch below unreachable: a call site that forgot to
forward its client was presented as the default and passed against a
default-bound database, which is exactly the omission this exists to
catch.
354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 |
# File 'lib/parse/mongodb.rb', line 354 def verify_client!(client) caller_scope = app_scope_for(client) bound = binding_scope!(caller_scope) return if bound.nil? if caller_scope.nil? # An unidentified caller. Where only one application has ever been # seen this is unambiguous, which covers every single-application # deployment. Once a SECOND has been seen it is a real ambiguity, # and allowing it meant any call site that forgot to forward its # client silently read whichever database happened to be bound. # Completeness of that plumbing should not be the only thing # standing between two applications, so this fails closed. return if observed_scopes.size <= 1 raise ClientMismatch, "Parse::MongoDB received a direct query with no identifiable client, in a " \ "process where more than one Parse application has been seen " \ "(#{observed_scopes.map { |sc| describe_scope(sc) }.join(", ")}). " \ "The connection is bound to #{describe_scope(bound)}. Refusing rather than " \ "guessing: pass client: through this call path so the read can be checked." end return if caller_scope == bound raise ClientMismatch, "Parse::MongoDB is bound to #{describe_scope(bound)} but this query was " \ "authorized by a client for #{describe_scope(caller_scope)}. The MongoDB " \ "connection is process-global while authorization is per-client, so running " \ "this would resolve one application's identity and then read the other " \ "application's database. Note that a matching application id is not enough: " \ "the same id is commonly reused across staging and production, so the server " \ "url is compared too. Configure a separate process for each application, " \ "or route this query through REST instead of mongo-direct." 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).
540 541 542 543 544 545 546 547 548 549 550 551 |
# File 'lib/parse/mongodb.rb', line 540 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.
685 686 687 |
# File 'lib/parse/mongodb.rb', line 685 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.
800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 |
# File 'lib/parse/mongodb.rb', line 800 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.
834 835 836 837 838 839 840 841 842 843 844 845 846 847 |
# File 'lib/parse/mongodb.rb', line 834 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 |