parse-stack-next 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. checksums.yaml +7 -0
  2. data/.bundle/config +2 -0
  3. data/.env.sample +112 -0
  4. data/.env.test +10 -0
  5. data/.github/workflows/ruby.yml +36 -0
  6. data/.gitignore +49 -0
  7. data/.ruby-version +1 -0
  8. data/.solargraph.yml +22 -0
  9. data/CHANGELOG.md +5816 -0
  10. data/Gemfile +30 -0
  11. data/Gemfile.lock +175 -0
  12. data/LICENSE.txt +23 -0
  13. data/Makefile +63 -0
  14. data/README.md +5655 -0
  15. data/Rakefile +573 -0
  16. data/bin/console +38 -0
  17. data/bin/parse-console +136 -0
  18. data/bin/server +17 -0
  19. data/bin/setup +7 -0
  20. data/config/parse-config.json +12 -0
  21. data/docs/TEST_SERVER.md +271 -0
  22. data/docs/_config.yml +1 -0
  23. data/docs/mcp_guide.md +3484 -0
  24. data/docs/mongodb_direct_guide.md +1348 -0
  25. data/docs/mongodb_index_optimization_guide.md +631 -0
  26. data/examples/transaction_example.rb +219 -0
  27. data/lib/parse/acl_scope.rb +728 -0
  28. data/lib/parse/agent/cancellation_token.rb +80 -0
  29. data/lib/parse/agent/constraint_translator.rb +480 -0
  30. data/lib/parse/agent/describe.rb +420 -0
  31. data/lib/parse/agent/errors.rb +133 -0
  32. data/lib/parse/agent/mcp_client.rb +557 -0
  33. data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
  34. data/lib/parse/agent/mcp_rack_app.rb +1143 -0
  35. data/lib/parse/agent/mcp_server.rb +376 -0
  36. data/lib/parse/agent/metadata_audit.rb +259 -0
  37. data/lib/parse/agent/metadata_dsl.rb +733 -0
  38. data/lib/parse/agent/metadata_registry.rb +794 -0
  39. data/lib/parse/agent/pipeline_validator.rb +82 -0
  40. data/lib/parse/agent/prompts.rb +351 -0
  41. data/lib/parse/agent/rate_limiter.rb +158 -0
  42. data/lib/parse/agent/relation_graph.rb +162 -0
  43. data/lib/parse/agent/result_formatter.rb +453 -0
  44. data/lib/parse/agent/tools.rb +5489 -0
  45. data/lib/parse/agent.rb +3249 -0
  46. data/lib/parse/api/aggregate.rb +79 -0
  47. data/lib/parse/api/all.rb +26 -0
  48. data/lib/parse/api/analytics.rb +18 -0
  49. data/lib/parse/api/batch.rb +33 -0
  50. data/lib/parse/api/cloud_functions.rb +58 -0
  51. data/lib/parse/api/config.rb +125 -0
  52. data/lib/parse/api/files.rb +29 -0
  53. data/lib/parse/api/hooks.rb +117 -0
  54. data/lib/parse/api/objects.rb +146 -0
  55. data/lib/parse/api/path_segment.rb +75 -0
  56. data/lib/parse/api/push.rb +20 -0
  57. data/lib/parse/api/schema.rb +49 -0
  58. data/lib/parse/api/server.rb +50 -0
  59. data/lib/parse/api/sessions.rb +24 -0
  60. data/lib/parse/api/users.rb +250 -0
  61. data/lib/parse/atlas_search/index_manager.rb +353 -0
  62. data/lib/parse/atlas_search/result.rb +204 -0
  63. data/lib/parse/atlas_search/search_builder.rb +604 -0
  64. data/lib/parse/atlas_search/session.rb +253 -0
  65. data/lib/parse/atlas_search.rb +995 -0
  66. data/lib/parse/client/authentication.rb +97 -0
  67. data/lib/parse/client/batch.rb +234 -0
  68. data/lib/parse/client/body_builder.rb +240 -0
  69. data/lib/parse/client/caching.rb +203 -0
  70. data/lib/parse/client/logging.rb +293 -0
  71. data/lib/parse/client/profiling.rb +181 -0
  72. data/lib/parse/client/protocol.rb +91 -0
  73. data/lib/parse/client/request.rb +233 -0
  74. data/lib/parse/client/response.rb +208 -0
  75. data/lib/parse/client.rb +1104 -0
  76. data/lib/parse/clp_scope.rb +361 -0
  77. data/lib/parse/live_query/circuit_breaker.rb +256 -0
  78. data/lib/parse/live_query/client.rb +1001 -0
  79. data/lib/parse/live_query/configuration.rb +224 -0
  80. data/lib/parse/live_query/event.rb +115 -0
  81. data/lib/parse/live_query/event_queue.rb +272 -0
  82. data/lib/parse/live_query/health_monitor.rb +214 -0
  83. data/lib/parse/live_query/logging.rb +149 -0
  84. data/lib/parse/live_query/subscription.rb +294 -0
  85. data/lib/parse/live_query.rb +163 -0
  86. data/lib/parse/lookup_rewriter.rb +445 -0
  87. data/lib/parse/model/acl.rb +968 -0
  88. data/lib/parse/model/associations/belongs_to.rb +275 -0
  89. data/lib/parse/model/associations/collection_proxy.rb +435 -0
  90. data/lib/parse/model/associations/has_many.rb +597 -0
  91. data/lib/parse/model/associations/has_one.rb +158 -0
  92. data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
  93. data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
  94. data/lib/parse/model/bytes.rb +62 -0
  95. data/lib/parse/model/classes/audience.rb +262 -0
  96. data/lib/parse/model/classes/installation.rb +363 -0
  97. data/lib/parse/model/classes/job_schedule.rb +153 -0
  98. data/lib/parse/model/classes/job_status.rb +264 -0
  99. data/lib/parse/model/classes/product.rb +75 -0
  100. data/lib/parse/model/classes/push_status.rb +263 -0
  101. data/lib/parse/model/classes/role.rb +751 -0
  102. data/lib/parse/model/classes/session.rb +201 -0
  103. data/lib/parse/model/classes/user.rb +943 -0
  104. data/lib/parse/model/clp.rb +544 -0
  105. data/lib/parse/model/core/actions.rb +1268 -0
  106. data/lib/parse/model/core/builder.rb +139 -0
  107. data/lib/parse/model/core/create_lock.rb +386 -0
  108. data/lib/parse/model/core/describe.rb +382 -0
  109. data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
  110. data/lib/parse/model/core/errors.rb +38 -0
  111. data/lib/parse/model/core/fetching.rb +566 -0
  112. data/lib/parse/model/core/field_guards.rb +220 -0
  113. data/lib/parse/model/core/indexing.rb +382 -0
  114. data/lib/parse/model/core/parse_reference.rb +407 -0
  115. data/lib/parse/model/core/properties.rb +809 -0
  116. data/lib/parse/model/core/querying.rb +491 -0
  117. data/lib/parse/model/core/schema.rb +202 -0
  118. data/lib/parse/model/core/search_indexing.rb +174 -0
  119. data/lib/parse/model/date.rb +88 -0
  120. data/lib/parse/model/email.rb +213 -0
  121. data/lib/parse/model/file.rb +527 -0
  122. data/lib/parse/model/geojson.rb +271 -0
  123. data/lib/parse/model/geopoint.rb +261 -0
  124. data/lib/parse/model/model.rb +260 -0
  125. data/lib/parse/model/object.rb +2068 -0
  126. data/lib/parse/model/phone.rb +520 -0
  127. data/lib/parse/model/pointer.rb +443 -0
  128. data/lib/parse/model/polygon.rb +406 -0
  129. data/lib/parse/model/push.rb +975 -0
  130. data/lib/parse/model/shortnames.rb +8 -0
  131. data/lib/parse/model/time_zone.rb +141 -0
  132. data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
  133. data/lib/parse/model/validations.rb +96 -0
  134. data/lib/parse/mongodb.rb +2300 -0
  135. data/lib/parse/pipeline_security.rb +554 -0
  136. data/lib/parse/query/constraint.rb +198 -0
  137. data/lib/parse/query/constraints.rb +3279 -0
  138. data/lib/parse/query/cursor.rb +434 -0
  139. data/lib/parse/query/n_plus_one_detector.rb +445 -0
  140. data/lib/parse/query/operation.rb +104 -0
  141. data/lib/parse/query/ordering.rb +66 -0
  142. data/lib/parse/query.rb +7028 -0
  143. data/lib/parse/schema/index_migrator.rb +291 -0
  144. data/lib/parse/schema/search_index_migrator.rb +289 -0
  145. data/lib/parse/schema.rb +494 -0
  146. data/lib/parse/stack/generators/rails.rb +40 -0
  147. data/lib/parse/stack/generators/templates/model.erb +51 -0
  148. data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
  149. data/lib/parse/stack/generators/templates/model_role.rb +4 -0
  150. data/lib/parse/stack/generators/templates/model_session.rb +4 -0
  151. data/lib/parse/stack/generators/templates/model_user.rb +11 -0
  152. data/lib/parse/stack/generators/templates/parse.rb +12 -0
  153. data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
  154. data/lib/parse/stack/railtie.rb +18 -0
  155. data/lib/parse/stack/tasks.rb +563 -0
  156. data/lib/parse/stack/version.rb +11 -0
  157. data/lib/parse/stack.rb +455 -0
  158. data/lib/parse/two_factor_auth/user_extension.rb +449 -0
  159. data/lib/parse/two_factor_auth.rb +310 -0
  160. data/lib/parse/webhooks/payload.rb +360 -0
  161. data/lib/parse/webhooks/registration.rb +199 -0
  162. data/lib/parse/webhooks/replay_protection.rb +189 -0
  163. data/lib/parse/webhooks.rb +510 -0
  164. data/lib/parse-stack-next.rb +5 -0
  165. data/lib/parse-stack.rb +5 -0
  166. data/parse-stack-next.gemspec +82 -0
  167. data/parse-stack.png +0 -0
  168. data/scripts/debug-ips.js +35 -0
  169. data/scripts/docker/Dockerfile.parse +13 -0
  170. data/scripts/docker/atlas-init.js +284 -0
  171. data/scripts/docker/docker-compose.atlas.yml +76 -0
  172. data/scripts/docker/docker-compose.test.yml +106 -0
  173. data/scripts/docker/mongo-init.js +21 -0
  174. data/scripts/eval_mcp_with_lm_studio.rb +274 -0
  175. data/scripts/start-parse.sh +90 -0
  176. data/scripts/start_mcp_server.rb +78 -0
  177. data/scripts/test_server_connection.rb +82 -0
  178. metadata +377 -0
@@ -0,0 +1,728 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+
4
+ require "set"
5
+ require_relative "model/acl"
6
+ require_relative "clp_scope"
7
+
8
+ module Parse
9
+ # Shared identity-resolution helper for query paths that simulate
10
+ # Parse Server's row-level ACL enforcement client-side because they
11
+ # bypass Parse Server entirely.
12
+ #
13
+ # The mongo-direct entry points (`Parse::MongoDB.aggregate`,
14
+ # `.geo_near`, `Parse::Query#results_direct`, `#count_direct`) talk
15
+ # to MongoDB through a connection authenticated by the URI configured
16
+ # in `Parse::MongoDB.configure`. From MongoDB's perspective that
17
+ # connection has full access — `_rperm` is just another field, not
18
+ # a security boundary. The SDK is therefore the *only* layer
19
+ # enforcing the row-level ACL that Parse Server would apply on a
20
+ # REST find. {ACLScope} produces the inputs that injection needs:
21
+ # the `_rperm` permission-string set for a session (`["*",
22
+ # userObjectId, "role:Admin", ...]`), so callers can prepend a
23
+ # `$match` stage built via {Parse::ACL.read_predicate}.
24
+ #
25
+ # Atlas Search uses the same pattern through
26
+ # `Parse::AtlasSearch::Session`; this module reuses that resolver
27
+ # (token → user_id → role expansion + caching) and adds a
28
+ # path-agnostic kwarg-popping front door so every mongo-direct entry
29
+ # point can speak the same auth vocabulary.
30
+ module ACLScope
31
+ # Raised when a query path is configured to require an explicit
32
+ # session-token or master mode and the caller supplied neither.
33
+ # Mirror of `Parse::AtlasSearch::ACLRequired`; both are accepted
34
+ # at SDK boundaries with the path's own name.
35
+ class ACLRequired < StandardError; end
36
+
37
+ # Outcome of resolving a single mongo-direct call's auth kwargs.
38
+ # @!attribute mode
39
+ # @return [Symbol] one of `:session`, `:master`, `:public`.
40
+ # `:session` means the caller passed a valid `session_token:`;
41
+ # `:master` means the caller passed `master: true`; `:public`
42
+ # means neither was supplied and the path's `require_session_token`
43
+ # toggle is off, so the SDK falls through to public-only ACL
44
+ # semantics.
45
+ # @!attribute permission_strings
46
+ # @return [Array<String>, nil] the `_rperm` allow-set ready to
47
+ # hand to {Parse::ACL.read_predicate}. `nil` for `:master` —
48
+ # no injection runs on the master path.
49
+ # @!attribute user_id
50
+ # @return [String, nil] the resolved user_id, or `nil` for
51
+ # `:master` and `:public`.
52
+ # @!attribute session
53
+ # @return [Parse::AtlasSearch::Session::Resolved, nil] the
54
+ # underlying resolved-session struct (carries role-name set),
55
+ # `nil` for `:master`.
56
+ # @!attribute strict_role
57
+ # @return [Boolean] when `true`, downstream predicate construction
58
+ # (see {.match_stage_for} and {.rewrite_pipeline}) suppresses the
59
+ # implicit `"*"` (public) grant. Only meaningful for role-scoped
60
+ # resolutions where the caller wants to see ONLY rows whose
61
+ # `_rperm` explicitly includes one of the resolved role names,
62
+ # not every public-readable row in the collection. Defaults to
63
+ # `false` for backwards compatibility. Note: even with
64
+ # `strict_role: true`, rows with NO `_rperm` field still pass
65
+ # (Parse-Server treats absent `_rperm` as public-default); the
66
+ # knob only suppresses the `"*"` membership in the `$in` set.
67
+ Resolution = Struct.new(:mode, :permission_strings, :user_id, :session, :strict_role, keyword_init: true) do
68
+ def master?; mode == :master; end
69
+ def session?; mode == :session; end
70
+ def public?; mode == :public; end
71
+ def strict_role?; strict_role == true; end
72
+ end
73
+
74
+ class << self
75
+ # When `true`, every call to {.resolve!} that did NOT receive
76
+ # `session_token:` or `master: true` raises {ACLRequired} instead
77
+ # of falling through to the public-only banner-and-continue path.
78
+ # Mirror of `Parse::AtlasSearch.require_session_token`. Default
79
+ # is `false` to preserve backwards compatibility with mongo-direct
80
+ # callsites that pre-date the session-token kwarg.
81
+ # @return [Boolean]
82
+ attr_accessor :require_session_token
83
+
84
+ # Resolve the auth-related kwargs (`:session_token`, `:master`)
85
+ # off `options` and return a {Resolution} describing which mode
86
+ # the call will run in. **Mutates `options`** by `delete`-ing
87
+ # the auth kwargs so the caller can forward the remaining hash
88
+ # to its underlying transport without leaking them.
89
+ #
90
+ # @param options [Hash] kwargs Hash the caller will forward;
91
+ # `:session_token` and `:master` are removed in place.
92
+ # @param method_name [Symbol] for error messages — typically the
93
+ # public entry-point name (`:aggregate`, `:geo_near`,
94
+ # `:results_direct`).
95
+ # @return [Resolution]
96
+ # @raise [ArgumentError] when both `session_token:` and
97
+ # `master: true` are supplied — they are mutually exclusive.
98
+ # @raise [ACLRequired] when neither is supplied and
99
+ # {.require_session_token} is `true`.
100
+ def resolve!(options, method_name:)
101
+ session_token = options.delete(:session_token)
102
+ master = options.delete(:master)
103
+ acl_user = options.delete(:acl_user)
104
+ acl_role = options.delete(:acl_role)
105
+ # `strict_role:` is only meaningful for the `acl_role:` branch
106
+ # below — it tells `resolve_for_role` to suppress the implicit
107
+ # `"*"` grant in the resulting permission set. We `delete` it
108
+ # unconditionally to avoid forwarding it to the underlying
109
+ # transport, and silently ignore on the non-role paths
110
+ # (session-token / acl_user / master / public) where it has no
111
+ # meaning. Defaults to `false` so the auto-public grant remains
112
+ # the legacy behavior.
113
+ strict_role = options.delete(:strict_role) == true
114
+
115
+ provided = [session_token, master == true ? master : nil, acl_user, acl_role].compact
116
+ if provided.length > 1
117
+ raise ArgumentError,
118
+ "Parse::ACLScope.#{method_name}: cannot pass more than one of " \
119
+ "session_token:, master: true, acl_user:, or acl_role:. Pick one."
120
+ end
121
+
122
+ if acl_user
123
+ # Pre-resolved User-pointer path used by
124
+ # Parse::Query#scope_to_user. Mirrors the session-token path
125
+ # but skips the /users/me round-trip; role expansion still
126
+ # runs via Parse::Role.all_for_user.
127
+ return resolve_for_user(acl_user)
128
+ end
129
+
130
+ if acl_role
131
+ # Role-only path used by Parse::Query#scope_to_role.
132
+ # Simulates "what would a user holding this role see"
133
+ # without minting a session token or knowing a specific
134
+ # user — useful for service-account-style queries (cron
135
+ # jobs, internal reporting, agentic tooling) where the
136
+ # caller wants role-grade access without a per-user
137
+ # identity. Parent-role inheritance applies (passing
138
+ # "scope:admin" includes any role "scope:admin" inherits
139
+ # from).
140
+ return resolve_for_role(acl_role, strict_role: strict_role)
141
+ end
142
+
143
+ if session_token
144
+ require_atlas_session!
145
+ resolved = Parse::AtlasSearch::Session.resolve(session_token)
146
+ return Resolution.new(
147
+ mode: :session,
148
+ permission_strings: resolved.permission_strings,
149
+ user_id: resolved.user_id,
150
+ session: resolved,
151
+ )
152
+ end
153
+
154
+ if master == true
155
+ return Resolution.new(mode: :master, permission_strings: nil, user_id: nil, session: nil)
156
+ end
157
+
158
+ if @require_session_token == true
159
+ raise ACLRequired,
160
+ "Parse::#{method_name} requires session_token: or master: true. " \
161
+ "Mongo-direct queries bypass Parse Server's ACL enforcement, so " \
162
+ "the SDK refuses to run them without an explicit identity or an " \
163
+ "explicit master-mode opt-in. Flip Parse::ACLScope.require_session_token " \
164
+ "= false to allow public-only fallback."
165
+ end
166
+
167
+ warn_no_acl_context_once!(method_name)
168
+ require_atlas_session!
169
+ anonymous = Parse::AtlasSearch::Session::Resolved.new(nil, Set.new)
170
+ Resolution.new(
171
+ mode: :public,
172
+ permission_strings: anonymous.permission_strings,
173
+ user_id: nil,
174
+ session: anonymous,
175
+ )
176
+ end
177
+
178
+ # Compile the `_rperm` `$match` stage to prepend to a mongo-direct
179
+ # pipeline. Returns `nil` on the master path (no injection), for
180
+ # `nil` resolutions (defensive — should never happen in normal
181
+ # use), and for legacy (non-strict-role) resolutions with an
182
+ # empty/nil perm set. Strict-role resolutions FAIL CLOSED: even
183
+ # an empty perm set still emits a $match (`$in: []` plus the
184
+ # `$exists: false` branch) so the caller cannot accidentally see
185
+ # every row. The shape comes straight from
186
+ # {Parse::ACL.read_predicate} and matches what
187
+ # {Parse::AtlasSearch} injects on its `$search` pipelines.
188
+ #
189
+ # @param resolution [Resolution, nil]
190
+ # @return [Hash, nil] a `$match` pipeline stage, or `nil`.
191
+ def match_stage_for(resolution)
192
+ return nil if resolution.nil? || resolution.master?
193
+ perms = resolution.permission_strings
194
+ strict = resolution.respond_to?(:strict_role?) && resolution.strict_role?
195
+ # Legacy (non-strict) behavior: an empty/nil perm set means
196
+ # nothing to inject, fall through with no $match. Strict-role
197
+ # mode FAIL-CLOSED: even an empty resolved-role set must still
198
+ # produce a predicate so the caller doesn't accidentally see
199
+ # every row. With `include_public: false` and empty perms, the
200
+ # predicate becomes `{$or: [{_rperm: {$in: []}}, {_rperm:
201
+ # {$exists: false}}]}` — only no-_rperm rows pass, which is
202
+ # the conservative interpretation.
203
+ return nil if !strict && (perms.nil? || perms.empty?)
204
+ perms = [] if perms.nil?
205
+ # `strict_role?` (defaults to `false`) suppresses the implicit
206
+ # `"*"` append that Parse::ACL.read_predicate normally performs.
207
+ # Used by role-scoped resolutions that opted into strict mode
208
+ # so a service-account-style query for, say, `acl_role:
209
+ # "scope:reporting"` does NOT see every public-readable row in
210
+ # the queried class.
211
+ { "$match" => Parse::ACL.read_predicate(perms, include_public: !strict) }
212
+ end
213
+
214
+ # Walk an aggregation pipeline and rewrite every join-style stage
215
+ # so its sub-results are filtered against the resolution's
216
+ # `_rperm` allow-set. Without this rewriting, a top-level
217
+ # `$match` injection only filters the queried collection's rows;
218
+ # any rows pulled in via `$lookup`, `$unionWith`, or
219
+ # `$graphLookup` are visible to the requesting session regardless
220
+ # of their stored ACL — a silent SDK-side ACL bypass on
221
+ # included/joined data.
222
+ #
223
+ # The rewriter handles:
224
+ #
225
+ # * **`$lookup`** — both simple (`from`/`localField`/`foreignField`)
226
+ # and pipeline forms. Simple form is upgraded to the
227
+ # combined form (Mongo 5.0+) by appending an `_rperm` match
228
+ # to its `pipeline`. Pipeline form prepends the same stage.
229
+ # * **`$unionWith`** — the unioned collection's rows are
230
+ # filtered by prepending an `_rperm` match to its `pipeline`
231
+ # (constructing one if absent).
232
+ # * **`$graphLookup`** — appends an `_rperm` match by way of
233
+ # a `restrictSearchWithMatch` clause (MongoDB's documented
234
+ # mechanism for filtering traversed rows).
235
+ # * **`$facet`** — recursive: each facet branch is itself a
236
+ # pipeline; rewrite every branch independently.
237
+ #
238
+ # Returns a NEW Array; the input pipeline is not mutated.
239
+ # Master and nil-resolution pass through unchanged. Legacy
240
+ # (non-strict-role) empty-perms resolutions also pass through.
241
+ # Strict-role empty-perms FAIL CLOSED (same contract as
242
+ # {.match_stage_for}): the ACL match is still injected so joined
243
+ # collections are filtered, not exposed.
244
+ #
245
+ # @param pipeline [Array<Hash>] the aggregation pipeline.
246
+ # @param resolution [Resolution, nil]
247
+ # @return [Array<Hash>] the rewritten pipeline.
248
+ def rewrite_pipeline(pipeline, resolution)
249
+ return pipeline if pipeline.nil? || pipeline.empty?
250
+ return pipeline if resolution.nil? || resolution.master?
251
+ perms = resolution.permission_strings
252
+ strict = resolution.respond_to?(:strict_role?) && resolution.strict_role?
253
+ # Same fail-closed contract as {.match_stage_for}: legacy mode
254
+ # passes through unmodified when perms are empty, strict-role
255
+ # mode still emits the conservative predicate.
256
+ return pipeline if !strict && (perms.nil? || perms.empty?)
257
+ perms = [] if perms.nil?
258
+
259
+ # Mirror the `strict_role?` handling in {.match_stage_for} so
260
+ # the predicate prepended to $lookup / $unionWith / $graphLookup
261
+ # / $facet sub-pipelines also suppresses the implicit `"*"`
262
+ # grant for strict-role resolutions.
263
+ acl_match = { "$match" => Parse::ACL.read_predicate(perms, include_public: !strict) }
264
+ # Pass `perms` alongside `acl_match` so every join-style stage
265
+ # rewriter can fire {Parse::CLPScope.permits?} on its joined
266
+ # target class. Without this gate, a scoped session that lacked
267
+ # `find` on `_User` could still surface `_User` rows by reading
268
+ # them through `$lookup.from: "_User"` inside an aggregation
269
+ # rooted on a public class. The agent dispatcher already had
270
+ # this gate; the rewriter is the shared SDK-level layer so the
271
+ # mongo-direct path enforces it independent of whether an agent
272
+ # made the call.
273
+ pipeline.map { |stage| rewrite_stage(stage, acl_match, perms) }
274
+ end
275
+
276
+ # Walk the result documents and redact every embedded sub-document
277
+ # whose stored `_rperm` does not include any of the resolution's
278
+ # permission strings. This is the second enforcement layer — the
279
+ # pipeline rewriter catches what it can reach, this catches what
280
+ # leaked through (raw `:object` columns embedding pointer-shaped
281
+ # hashes, `$lookup` stages the rewriter couldn't rewrite, etc.).
282
+ #
283
+ # Redaction is in-place tree mutation. Each embedded sub-document
284
+ # carrying `_rperm` is either kept as-is, replaced with `nil`
285
+ # (when value is a scalar field), or removed from its containing
286
+ # Array (when value is an array element). Sub-documents without
287
+ # `_rperm` are treated as public-readable and pass through. The
288
+ # top-level documents are NOT redacted by this walk — the
289
+ # top-level `$match` injection already filtered those.
290
+ #
291
+ # @param documents [Array<Hash>] the result rows.
292
+ # @param resolution [Resolution, nil]
293
+ # @return [Array<Hash>] the same Array, with embedded sub-docs
294
+ # redacted in place.
295
+ def redact_results!(documents, resolution)
296
+ return documents if documents.nil? || documents.empty?
297
+ return documents if resolution.nil? || resolution.master?
298
+ perms = resolution.permission_strings
299
+ return documents if perms.nil? || perms.empty?
300
+
301
+ perms_set = perms.is_a?(Set) ? perms : perms.to_set
302
+ documents.each { |doc| redact_subdocs!(doc, perms_set, top: true) }
303
+ documents
304
+ end
305
+
306
+ private
307
+
308
+ # Apply the rewriter to a single pipeline stage. Operator-aware:
309
+ # only join-style stages are touched. Everything else passes
310
+ # through verbatim. `perms` is threaded down so the cross-class
311
+ # CLP gate (Wave-3 TRACK-ACL-3) can challenge each joined class.
312
+ def rewrite_stage(stage, acl_match, perms)
313
+ return stage unless stage.is_a?(Hash)
314
+ op_key, op_val = stage.first
315
+ case op_key.to_s
316
+ when "$lookup"
317
+ { op_key => rewrite_lookup(op_val, acl_match, perms) }
318
+ when "$unionWith"
319
+ { op_key => rewrite_union_with(op_val, acl_match, perms) }
320
+ when "$graphLookup"
321
+ { op_key => rewrite_graph_lookup(op_val, acl_match, perms) }
322
+ when "$facet"
323
+ { op_key => rewrite_facet(op_val, acl_match, perms) }
324
+ else
325
+ stage
326
+ end
327
+ end
328
+
329
+ # Cross-class CLP gate. Raises {Parse::CLPScope::Denied} when
330
+ # the current scope cannot `find` rows of `target_class`. Master
331
+ # mode is already short-circuited in {.rewrite_pipeline} (it
332
+ # never reaches the rewriters), so reaching this helper means
333
+ # `perms` is a real claim set. Centralized here to avoid drift
334
+ # between the three join-style rewriters.
335
+ def assert_join_target_permitted!(target, perms)
336
+ return if target.nil?
337
+ target_str = target.to_s
338
+ return if target_str.empty?
339
+ return if Parse::CLPScope.permits?(target_str, :find, perms)
340
+ raise Parse::CLPScope::Denied.new(
341
+ target_str, :find,
342
+ "Joined class '#{target_str}' refuses :find for current scope.",
343
+ )
344
+ end
345
+
346
+ def rewrite_lookup(spec, acl_match, perms)
347
+ # String shorthand `{$lookup: "Collection"}` is not a real
348
+ # Mongo form; defensively leave it alone.
349
+ return spec unless spec.is_a?(Hash)
350
+ # Gate FIRST so a CLP-denied join is refused before the
351
+ # rewriter spends work rebuilding the sub-pipeline. `from`
352
+ # accepts string or symbol — normalize via the gate.
353
+ target = spec["from"] || spec[:from]
354
+ assert_join_target_permitted!(target, perms)
355
+ spec = spec.dup
356
+ existing_pipeline = spec["pipeline"] || spec[:pipeline] || []
357
+ # Walk the sub-pipeline recursively so nested $lookup /
358
+ # $unionWith / $graphLookup inside the join's pipeline are
359
+ # themselves CLP-gated and ACL-rewritten against the SAME
360
+ # `perms` set. (Mongo evaluates the sub-pipeline in the
361
+ # joined collection's context, but the requesting session is
362
+ # unchanged; permissions don't elevate by traversing a join.)
363
+ rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, acl_match, perms) }
364
+ new_pipeline = [acl_match] + rewritten_inner
365
+ spec["pipeline"] = new_pipeline
366
+ spec.delete(:pipeline) # symbol form was promoted to string form
367
+ spec
368
+ end
369
+
370
+ def rewrite_union_with(spec, acl_match, perms)
371
+ # `$unionWith` accepts either a String (collection name only)
372
+ # or a Hash `{coll:, pipeline:}`. Capture the target from
373
+ # either shape so the CLP gate fires before the String→Hash
374
+ # upgrade — denying access to the joined class BEFORE we go
375
+ # to the trouble of building out an upgraded sub-pipeline.
376
+ target =
377
+ if spec.is_a?(String)
378
+ spec
379
+ elsif spec.is_a?(Hash)
380
+ spec["coll"] || spec[:coll]
381
+ end
382
+ assert_join_target_permitted!(target, perms)
383
+
384
+ if spec.is_a?(String)
385
+ return { "coll" => spec, "pipeline" => [acl_match] }
386
+ end
387
+ return spec unless spec.is_a?(Hash)
388
+ spec = spec.dup
389
+ existing_pipeline = spec["pipeline"] || spec[:pipeline] || []
390
+ rewritten_inner = existing_pipeline.map { |s| rewrite_stage(s, acl_match, perms) }
391
+ spec["pipeline"] = [acl_match] + rewritten_inner
392
+ spec.delete(:pipeline)
393
+ spec
394
+ end
395
+
396
+ def rewrite_graph_lookup(spec, acl_match, perms)
397
+ return spec unless spec.is_a?(Hash)
398
+ # Same CLP gate, same reasoning — $graphLookup reads from a
399
+ # different collection in the same session's authority.
400
+ target = spec["from"] || spec[:from]
401
+ assert_join_target_permitted!(target, perms)
402
+ spec = spec.dup
403
+ # `$graphLookup` doesn't accept a sub-pipeline. Its filter hook
404
+ # is `restrictSearchWithMatch`, which is a $match-predicate (no
405
+ # `$match` wrapper). Combine with any existing restriction via
406
+ # `$and`.
407
+ acl_predicate = acl_match["$match"]
408
+ existing = spec["restrictSearchWithMatch"] || spec[:restrictSearchWithMatch]
409
+ combined =
410
+ if existing.nil? || (existing.respond_to?(:empty?) && existing.empty?)
411
+ acl_predicate
412
+ else
413
+ { "$and" => [existing, acl_predicate] }
414
+ end
415
+ spec["restrictSearchWithMatch"] = combined
416
+ spec.delete(:restrictSearchWithMatch)
417
+ spec
418
+ end
419
+
420
+ def rewrite_facet(spec, acl_match, perms)
421
+ return spec unless spec.is_a?(Hash)
422
+ spec.each_with_object({}) do |(branch_name, branch_pipeline), out|
423
+ out[branch_name] =
424
+ if branch_pipeline.is_a?(Array)
425
+ # Recurse with the same perms — facet branches are
426
+ # evaluated in the requesting session's authority, not
427
+ # elevated.
428
+ branch_pipeline.map { |s| rewrite_stage(s, acl_match, perms) }
429
+ else
430
+ branch_pipeline
431
+ end
432
+ end
433
+ end
434
+
435
+ # Maximum recursion depth for {.redact_subdocs!}. Bounds the
436
+ # walker so a self-referential (cyclic) result-row Hash — which
437
+ # MongoDB doesn't normally produce, but which a malicious or
438
+ # buggy upstream replaying an unsanitized payload could
439
+ # construct — cannot trigger a SystemStackError. The default of
440
+ # 32 comfortably covers realistic Parse Server result shapes
441
+ # (which rarely exceed ~6 levels of nesting via $lookup +
442
+ # embedded pointer hashes) while leaving enough headroom that
443
+ # legitimate deeply-nested aggregation outputs aren't truncated.
444
+ DEFAULT_REDACT_MAX_DEPTH = 32
445
+
446
+ # Walk one document, redacting embedded sub-documents that don't
447
+ # satisfy the perms set. The `top:` flag is `true` on the entry
448
+ # call (the result row itself, which the top-level $match
449
+ # already filtered) so we descend into its fields but don't
450
+ # redact the row itself.
451
+ #
452
+ # `depth:` decrements on each recursion; on exhaustion the
453
+ # subtree is treated as "redact" — the conservative choice (drop
454
+ # the offending branch rather than recurse-forever). Raising
455
+ # would abort the entire result set on one bad row, which is
456
+ # noisier than the redactor's protocol elsewhere.
457
+ def redact_subdocs!(node, perms_set, top: false, depth: DEFAULT_REDACT_MAX_DEPTH)
458
+ # Depth exhausted: treat the subtree as ACL-failing and let
459
+ # the caller drop it. Conservative-by-construction; the
460
+ # alternative (return `nil` and silently pass the subtree
461
+ # through) would let a deeply-nested or cyclic payload bypass
462
+ # both ACL enforcement AND the recursion bound.
463
+ return :__redact if depth <= 0
464
+
465
+ case node
466
+ when Hash
467
+ if !top && node.key?("_rperm") && !rperm_matches?(node["_rperm"], perms_set)
468
+ # Caller (an Array#map!-style step or scalar field clear)
469
+ # handles the removal; signal back with :__redact.
470
+ return :__redact
471
+ end
472
+ node.each do |key, value|
473
+ next if key == "_rperm" || key == "_wperm" # leave ACL fields intact
474
+ outcome = redact_subdocs!(value, perms_set, depth: depth - 1)
475
+ if outcome == :__redact
476
+ node[key] = nil
477
+ elsif outcome.is_a?(Array)
478
+ node[key] = outcome
479
+ end
480
+ end
481
+ nil
482
+ when Array
483
+ filtered = node.each_with_object([]) do |element, acc|
484
+ outcome = redact_subdocs!(element, perms_set, depth: depth - 1)
485
+ if outcome == :__redact
486
+ # drop ACL-failing element
487
+ elsif outcome.is_a?(Array)
488
+ acc << outcome
489
+ else
490
+ acc << element
491
+ end
492
+ end
493
+ filtered
494
+ else
495
+ nil
496
+ end
497
+ end
498
+
499
+ # Decide whether an embedded sub-document's stored `_rperm` field
500
+ # satisfies the current scope's permission set.
501
+ #
502
+ # Convention (matches Parse Server's behavior on top-level rows):
503
+ # - `nil`/absent `_rperm` = public-readable. Permit.
504
+ # - Array `_rperm` = standard storage form. Intersect with the
505
+ # permission set; permit on any match.
506
+ # - Anything else (String, Hash, Integer, ...) = malformed.
507
+ # FAIL CLOSED: we don't know how to interpret the field, so
508
+ # refuse rather than silently allow. A malformed `_rperm`
509
+ # typically indicates upstream data corruption, a schema drift,
510
+ # or — worst case — an attacker who managed to overwrite the
511
+ # field with a value that bypasses naive type-tolerant matchers.
512
+ # The previous behavior treated non-Array as nil (public),
513
+ # which silently surrendered the redaction guarantee. We warn
514
+ # once per `_rperm` value-class seen so operators can spot the
515
+ # corruption rather than just discovering rows disappear.
516
+ def rperm_matches?(stored_rperm, perms_set)
517
+ return true if stored_rperm.nil?
518
+ unless stored_rperm.is_a?(Array)
519
+ warn_malformed_rperm_once!(stored_rperm.class)
520
+ return false
521
+ end
522
+ stored_rperm.any? { |entry| perms_set.include?(entry) }
523
+ end
524
+
525
+ # Emit a one-shot-per-process `warn` the first time a given
526
+ # non-Array `_rperm` value-class is observed. Keyed on the value
527
+ # class (String / Hash / Integer / etc.) so each distinct
528
+ # corruption shape surfaces at least once but doesn't spam the
529
+ # logs at request rate. Stored on the singleton (mirrors the
530
+ # `@no_acl_warned` pattern); avoids the `@@class_var` cross-
531
+ # inheritance leakage Ruby warns about.
532
+ def warn_malformed_rperm_once!(value_class)
533
+ @warned_malformed_rperm_classes ||= Set.new
534
+ return if @warned_malformed_rperm_classes.include?(value_class)
535
+ @warned_malformed_rperm_classes << value_class
536
+ warn "[Parse::ACLScope:SECURITY] Encountered malformed _rperm of " \
537
+ "type #{value_class}; the SDK fails CLOSED on non-Array " \
538
+ "_rperm to avoid silently surrendering row-level " \
539
+ "redaction. This usually indicates upstream data " \
540
+ "corruption or schema drift — investigate the document(s) " \
541
+ "with the malformed field. Subsequent occurrences of the " \
542
+ "same value-class will not re-warn."
543
+ end
544
+
545
+ public
546
+
547
+ # Build a {Resolution} directly from a pre-resolved User pointer
548
+ # (or User instance). Role-expansion runs through
549
+ # {Parse::Role.all_for_user} — same path the
550
+ # session-token resolver uses — but the token-to-user step is
551
+ # skipped because the caller already has the user. Used by
552
+ # {Parse::Query#scope_to_user} and any external code that wants
553
+ # to feed a User directly into the ACL simulation without going
554
+ # through a session token.
555
+ # @param user [Parse::User, Parse::Pointer]
556
+ # @return [Resolution]
557
+ def resolve_for_user(user)
558
+ # SECURITY: className must be `_User` (or the legacy `User`
559
+ # alias). Without this check, any duck-typed object exposing
560
+ # `#id` — including a `Parse::Pointer` to a foreign class
561
+ # such as `Order` or `AuditLog` — would be accepted, and its
562
+ # raw `user.id` would land verbatim in `perms` below. Parse
563
+ # objectIds are 10-char alphanumerics with no class
564
+ # segregation, so a foreign-class pointer whose objectId
565
+ # happened to equal a real `_User` objectId would simulate
566
+ # that user for ACL purposes (id-collision impersonation).
567
+ # The two acceptable shapes are a `Parse::User` instance or
568
+ # a `Parse::Pointer` whose `parse_class` is `_User`/`User`.
569
+ valid_user_class =
570
+ user.is_a?(Parse::User) ||
571
+ (user.is_a?(Parse::Pointer) &&
572
+ [Parse::Model::CLASS_USER, "User"].include?(user.parse_class))
573
+ unless valid_user_class
574
+ got_class = user.respond_to?(:parse_class) ? user.parse_class.inspect : "<no className>"
575
+ raise ArgumentError,
576
+ "Parse::ACLScope.resolve_for_user requires a Parse::User or a " \
577
+ "Pointer with className '_User'; got #{user.class}/#{got_class}. " \
578
+ "Refusing - non-_User pointer ids would land in the ACL " \
579
+ "permission_strings and grant cross-class id-collision " \
580
+ "impersonation."
581
+ end
582
+ unless user.respond_to?(:id) && user.id.is_a?(String) && !user.id.empty?
583
+ raise ArgumentError,
584
+ "Parse::ACLScope.resolve_for_user expects a Parse::User or " \
585
+ "User Pointer with a non-empty objectId."
586
+ end
587
+
588
+ role_names =
589
+ begin
590
+ require_relative "model/classes/role"
591
+ Parse::Role.all_for_user(user, max_depth: 10)
592
+ rescue StandardError
593
+ Set.new
594
+ end
595
+
596
+ perms = ["*", user.id]
597
+ role_names.each { |name| perms << "role:#{name}" if name && !name.empty? }
598
+ perms.uniq!
599
+
600
+ require_atlas_session!
601
+ Resolution.new(
602
+ mode: :session,
603
+ permission_strings: perms,
604
+ user_id: user.id,
605
+ session: Parse::AtlasSearch::Session::Resolved.new(user.id, role_names),
606
+ )
607
+ end
608
+
609
+ # Build a {Resolution} for a role-only scope: no user_id, just
610
+ # the role's name plus every role it transitively inherits from
611
+ # (parent-role chain). Useful for service-account-style queries
612
+ # ("see as if a user with the `admin` role were asking") without
613
+ # minting a session token or knowing a specific user.
614
+ #
615
+ # The inheritance walk uses {Parse::Role#all_parent_role_names},
616
+ # which is the same upward traversal {Parse::Role.all_for_user}
617
+ # uses to compose user permissions — so the perms set is
618
+ # consistent with what a real user holding the role would see.
619
+ #
620
+ # Accepts either a {Parse::Role} instance or a role name String
621
+ # (with or without the `"role:"` prefix). A String input
622
+ # triggers a `_Role.find_by(name:)` lookup and raises
623
+ # ArgumentError when the role doesn't exist.
624
+ #
625
+ # @param role [Parse::Role, String]
626
+ # @param strict_role [Boolean] when `true`, the returned
627
+ # {Resolution} signals downstream predicate construction to
628
+ # suppress the implicit `"*"` grant. The resolved permission
629
+ # set drops `"*"` (so a role-scoped query does NOT see every
630
+ # public-readable row in the queried class). Defaults to
631
+ # `false` for backwards compatibility — legacy callers that
632
+ # used `acl_role:` continue to see public rows as before. Note:
633
+ # even in strict mode, rows with no `_rperm` field continue to
634
+ # match because Parse Server treats them as public-default; see
635
+ # {.match_stage_for} for the precise predicate shape.
636
+ # @return [Resolution]
637
+ # @raise [ArgumentError] when the role cannot be resolved.
638
+ def resolve_for_role(role, strict_role: false)
639
+ require_relative "model/classes/role"
640
+ role_obj =
641
+ case role
642
+ when Parse::Role then role
643
+ when String, Symbol
644
+ name = role.to_s.sub(/\Arole:/, "")
645
+ raise ArgumentError, "[Parse::ACLScope] role name must be non-empty." if name.empty?
646
+ found = Parse::Role.first(name: name)
647
+ raise ArgumentError, "[Parse::ACLScope] no _Role found with name #{name.inspect}." if found.nil?
648
+ found
649
+ else
650
+ raise ArgumentError, "[Parse::ACLScope] resolve_for_role expects Parse::Role or String."
651
+ end
652
+
653
+ names =
654
+ begin
655
+ role_obj.all_parent_role_names(max_depth: 10)
656
+ rescue StandardError
657
+ Set.new([role_obj.name].compact)
658
+ end
659
+
660
+ # In strict mode the permission set omits the implicit `"*"`
661
+ # so the resulting predicate only matches rows whose `_rperm`
662
+ # contains one of the resolved role names (plus the standard
663
+ # `_rperm: {$exists: false}` branch — see Resolution#strict_role
664
+ # docs). In legacy mode `"*"` is included so role-scoped
665
+ # callers also see every public-readable row.
666
+ perms = strict_role ? [] : ["*"]
667
+ names.each { |n| perms << "role:#{n}" if n && !n.empty? }
668
+ perms.uniq!
669
+
670
+ require_atlas_session!
671
+ Resolution.new(
672
+ mode: :session,
673
+ permission_strings: perms,
674
+ user_id: nil,
675
+ session: Parse::AtlasSearch::Session::Resolved.new(nil, names),
676
+ strict_role: strict_role,
677
+ )
678
+ end
679
+
680
+ # Reset the "no ACL context" banner state AND the malformed
681
+ # `_rperm` one-shot registry. Test-only — without this hook the
682
+ # warned-once registries would persist across the suite and
683
+ # warning-presence assertions in later tests would flake.
684
+ # @!visibility private
685
+ def reset_warning_state!
686
+ @no_acl_warned = false
687
+ @warned_malformed_rperm_classes = Set.new
688
+ end
689
+
690
+ private
691
+
692
+ # Emit the once-per-process security banner the first time a
693
+ # mongo-direct path runs without `session_token:` and without
694
+ # `master: true`. Mirrors {Parse::AtlasSearch}'s warned-once
695
+ # pattern.
696
+ def warn_no_acl_context_once!(method_name)
697
+ return if @no_acl_warned == true
698
+ @no_acl_warned = true
699
+ warn "[Parse::ACLScope:SECURITY] #{method_name} called without " \
700
+ "session_token: or master: true. Mongo-direct paths bypass " \
701
+ "Parse Server's ACL enforcement; the pipeline will enforce " \
702
+ "public-only semantics (only documents readable by `\"*\"` " \
703
+ "or with no _rperm). Pass session_token: for per-user " \
704
+ "filtering, or master: true to confirm the master-mode " \
705
+ "bypass is intentional. Set Parse::ACLScope.require_session_token " \
706
+ "= true to make this misuse an error instead of a warning."
707
+ end
708
+
709
+ # Lazily load the Atlas Search session resolver — it carries the
710
+ # token-cache / role-cache plumbing this module reuses. Loads
711
+ # `atlas_search.rb` (not just `atlas_search/session.rb`) so the
712
+ # parent module's session_cache / role_cache are initialized.
713
+ # Loading session.rb in isolation leaves Parse::AtlasSearch
714
+ # without its memory caches and Session.lookup_user_id crashes
715
+ # with NoMethodError. Keeping the require lazy means apps that
716
+ # never call an auth-resolving path don't pay the load cost.
717
+ def require_atlas_session!
718
+ return if defined?(Parse::AtlasSearch) && Parse::AtlasSearch.respond_to?(:session_cache) &&
719
+ !Parse::AtlasSearch.session_cache.nil?
720
+ require_relative "atlas_search"
721
+ end
722
+ end
723
+
724
+ @require_session_token = false
725
+ @no_acl_warned = false
726
+ @warned_malformed_rperm_classes = Set.new
727
+ end
728
+ end