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,751 @@
1
+ # encoding: UTF-8
2
+ # frozen_string_literal: true
3
+ # Note: Do not require "../object" here - this file is loaded from object.rb
4
+ # and adding that require would create a circular dependency.
5
+ # user.rb is also loaded from object.rb before this file.
6
+
7
+ module Parse
8
+ # This class represents the data and columns contained in the standard Parse `_Role` collection.
9
+ # Roles allow the an application to group a set of {Parse::User} records with the same set of
10
+ # permissions, so that specific records in the database can have {Parse::ACL}s related to a role
11
+ # than trying to add all the users in a group.
12
+ #
13
+ # The default schema for {Role} is as follows:
14
+ #
15
+ # class Parse::Role < Parse::Object
16
+ # # See Parse::Object for inherited properties...
17
+ #
18
+ # property :name
19
+ #
20
+ # # A role may have child roles.
21
+ # has_many :roles, through: :relation
22
+ #
23
+ # # The set of users who belong to this role.
24
+ # has_many :users, through: :relation
25
+ # end
26
+ #
27
+ # @example Creating and managing roles
28
+ # # Create an admin role
29
+ # admin = Parse::Role.create(name: "Admin")
30
+ #
31
+ # # Add users to the role
32
+ # admin.add_user(user1)
33
+ # admin.add_users(user2, user3)
34
+ # admin.save
35
+ #
36
+ # # Create role hierarchy. Parse Server's _Role semantics: when role X
37
+ # # holds role Y in its `roles` relation, USERS OF Y INHERIT X'S
38
+ # # PERMISSIONS — not the other way around. So if you want Admins to
39
+ # # have everything Moderators can do, you must put Admin into the
40
+ # # Moderator role's `roles` relation:
41
+ # moderator = Parse::Role.create(name: "Moderator")
42
+ # moderator.add_child_role(admin) # Admins inherit Moderator permissions
43
+ # moderator.save
44
+ #
45
+ # # Query users in role (including child roles whose users implicitly
46
+ # # have this role through Parse's inheritance):
47
+ # all_users = moderator.all_users # includes Admin's users transitively
48
+ #
49
+ # @see Parse::Object
50
+ class Role < Parse::Object
51
+ parse_class Parse::Model::CLASS_ROLE
52
+ # @!attribute name
53
+ # @return [String] the name of this role.
54
+ property :name
55
+ # This attribute is mapped as a `has_many` Parse relation association with the {Parse::Role} class,
56
+ # as roles can be associated with multiple child roles to support role inheritance.
57
+ # The roles Parse relation provides a mechanism to create a hierarchical inheritable types of permissions
58
+ # by assigning child roles.
59
+ # @return [RelationCollectionProxy<Role>] a collection of Roles.
60
+ has_many :roles, through: :relation
61
+ # This attribute is mapped as a `has_many` Parse relation association with the {Parse::User} class.
62
+ # @return [RelationCollectionProxy<User>] a Parse relation of users belonging to this role.
63
+ has_many :users, through: :relation
64
+
65
+ # Parse Server requires every _Role row to ship with an ACL (the
66
+ # requirement is hard-coded in SchemaController.requiredColumns and
67
+ # cannot be disabled by config). We default to master-only (ACL = {})
68
+ # so anonymous clients cannot enumerate the role graph or read
69
+ # membership. Parse Server's internal role expansion runs with master
70
+ # context (Auth#getRolesForUser), so ACL evaluation continues to work
71
+ # without a public-read grant. Apps that need broader access should
72
+ # pass `acl:` to find_or_create / assign `role.acl=` explicitly.
73
+ acl_policy :private
74
+
75
+ class << self
76
+ # Find a role by its name.
77
+ # @param role_name [String] the name of the role to find.
78
+ # @return [Parse::Role, nil] the role if found, nil otherwise.
79
+ # @example
80
+ # admin = Parse::Role.find_by_name("Admin")
81
+ def find_by_name(role_name)
82
+ query(name: role_name).first
83
+ end
84
+
85
+ # Find or create a role by name.
86
+ # @param role_name [String] the name of the role.
87
+ # @param acl [Parse::ACL] optional ACL to set on creation.
88
+ # @return [Parse::Role] the existing or newly created role.
89
+ # @example
90
+ # admin = Parse::Role.find_or_create("Admin")
91
+ def find_or_create(role_name, acl: nil)
92
+ role = find_by_name(role_name)
93
+ return role if role
94
+
95
+ role = new(name: role_name)
96
+ role.acl = acl if acl
97
+ role.save
98
+ role
99
+ end
100
+
101
+ # Get all role names in the system.
102
+ # @return [Array<String>] array of role names.
103
+ def all_names
104
+ query.results.map(&:name)
105
+ end
106
+
107
+ # Check if a role with the given name exists.
108
+ # @param role_name [String] the name to check.
109
+ # @return [Boolean] true if role exists.
110
+ def exists?(role_name)
111
+ query(name: role_name).count > 0
112
+ end
113
+
114
+ # Return the transitive upward closure of role names a user
115
+ # inherits permissions from.
116
+ #
117
+ # Parse Server +_Role+ inheritance: when role +X+ holds role +Y+
118
+ # in its +roles+ relation, users of +Y+ inherit +X+'s
119
+ # permissions. So given a user +U+, the permission set is built
120
+ # by:
121
+ #
122
+ # 1. Querying for every role +D+ where +U+ is a direct member
123
+ # (+_Role.users+ contains +U+).
124
+ # 2. For each direct role +D+, walking upward to every role
125
+ # +P+ that lists +D+ in its +roles+ relation. Repeat until
126
+ # no new parents are found.
127
+ #
128
+ # This is the correct primitive for building +_rperm+ predicates
129
+ # (e.g., {ACLReadableByConstraint}, {ACLWritableByConstraint},
130
+ # and the Atlas Search ACL +$match+ injection). The legacy walk
131
+ # via {#all_child_roles} on the user's direct roles traverses
132
+ # the wrong direction and over-grants — it returns roles whose
133
+ # users include the input user through inheritance, not the
134
+ # roles the input user inherits permissions from.
135
+ #
136
+ # Cycle-safe: a visited-id set guards against pathological
137
+ # +_Role.roles+ cycles (e.g. A→B→A).
138
+ #
139
+ # @param user [Parse::User, Parse::Pointer, String, nil] the
140
+ # user to expand. A +Parse::Pointer+ must be on the +_User+
141
+ # class. A +String+ is treated as a +_User+ objectId. +nil+
142
+ # returns an empty set (anonymous).
143
+ # @param max_depth [Integer] maximum BFS depth (default: 10).
144
+ # @param master [Boolean] when +true+, opt in to the mongo-direct
145
+ # fast path under master-mode (bypasses `_Role` CLP). Use for
146
+ # admin/analytics code paths that legitimately need a
147
+ # master-scope view of the role graph.
148
+ # @param as [Parse::User, Parse::Pointer, nil] when supplied,
149
+ # opt in to the mongo-direct fast path under the caller's
150
+ # scope (subject to `_Role` CLP). The scope is forwarded
151
+ # verbatim to {Parse::MongoDB.role_names_for_user}; CLP denial
152
+ # raises {Parse::CLPScope::Denied}.
153
+ # @return [Set<String>] role names (no +role:+ prefix) the user
154
+ # transitively inherits permissions from, including direct
155
+ # memberships. Empty set for anonymous or no-membership users.
156
+ # @note When neither `master:` nor `as:` is supplied, the
157
+ # mongo-direct fast path is **skipped**; the method falls
158
+ # through to the Parse-Server walk
159
+ # (`Parse::Role.all(users: user_pointer)`) which goes through
160
+ # the default Parse::Client. This preserves backward
161
+ # compatibility for the many SDK-internal call sites that
162
+ # compose ACL scopes (acl_scope, atlas_search session,
163
+ # query/constraints) — none of those have a caller scope to
164
+ # forward. The fast path is opt-in for performance-conscious
165
+ # callers that can supply explicit authorization.
166
+ # @example
167
+ # names = Parse::Role.all_for_user(user, master: true) # admin/analytics
168
+ # names = Parse::Role.all_for_user(user, as: current_user) # scope-checked
169
+ def all_for_user(user, max_depth: 10, master: false, as: nil)
170
+ names = Set.new
171
+ return names if user.nil? || max_depth <= 0
172
+
173
+ user_pointer = role_lookup_pointer_for(user)
174
+ return names if user_pointer.nil?
175
+
176
+ # The fast path is opt-in. When neither `master:` nor `as:` is
177
+ # supplied, skip it entirely — the underlying mongo helper
178
+ # would raise ArgumentError, and we don't want to surprise
179
+ # the many backward-compat call sites (acl_scope.resolve_for_user,
180
+ # atlas_search Session.role_names_for, query/constraints' ACL
181
+ # constraint building, agent default-scope composition) that
182
+ # have no scope to forward.
183
+ if master == true || !as.nil?
184
+ fast_path_result = all_for_user_mongo_fast_path(
185
+ user_pointer.id, max_depth, master: master, as: as,
186
+ )
187
+ if fast_path_result.is_a?(Set)
188
+ ActiveSupport::Notifications.instrument(
189
+ "parse.role.expand",
190
+ direction: :forward, target_id: user_pointer.id,
191
+ depth: max_depth, source: :mongo_direct,
192
+ result_count: fast_path_result.size,
193
+ )
194
+ return fast_path_result
195
+ end
196
+ end
197
+
198
+ begin
199
+ direct_roles = Parse::Role.all(users: user_pointer)
200
+ rescue
201
+ return names
202
+ end
203
+
204
+ result = expand_inheritance_upward(direct_roles, max_depth: max_depth)
205
+ ActiveSupport::Notifications.instrument(
206
+ "parse.role.expand",
207
+ direction: :forward, target_id: user_pointer.id,
208
+ depth: max_depth, source: :parse_server,
209
+ result_count: result.size,
210
+ )
211
+ result
212
+ end
213
+
214
+ # @!visibility private
215
+ # Try the mongo-direct fast path for {.all_for_user}. Returns the
216
+ # resolved `Set<String>` on success, or `nil` when the fast path
217
+ # is unavailable (mongo not configured, or a benign availability
218
+ # error). Attack-signal errors (timeouts, denied operators,
219
+ # CLP::Denied, ArgumentError on missing auth) are propagated.
220
+ def all_for_user_mongo_fast_path(user_id, max_depth, master: false, as: nil)
221
+ return nil unless defined?(Parse::MongoDB)
222
+ return nil unless Parse::MongoDB.respond_to?(:role_names_for_user)
223
+ Parse::MongoDB.role_names_for_user(
224
+ user_id, max_depth: max_depth, master: master, as: as,
225
+ )
226
+ rescue StandardError => e
227
+ # Fall back to Parse-Server path on benign availability errors
228
+ # (lost connection mid-query). Propagate everything else —
229
+ # ExecutionTimeout, DeniedOperator, CLPScope::Denied,
230
+ # ArgumentError, and any unrecognized Mongo::Error subclass —
231
+ # so attack signals aren't masked by a silent slow-path retry.
232
+ if defined?(::Mongo::Error::ConnectionFailure) &&
233
+ e.is_a?(::Mongo::Error::ConnectionFailure)
234
+ # Emit a structured event so operators can observe the
235
+ # fast-path-unavailable rate (e.g. analytics-replica
236
+ # connection flapping). The fallback to the Parse-Server
237
+ # walk that follows enforces ACL on its own; this notification
238
+ # exists solely to surface the discrepancy.
239
+ ActiveSupport::Notifications.instrument(
240
+ "parse.role.fast_path_unavailable",
241
+ reason: "connection_failure", direction: :forward,
242
+ target_id: user_id, depth: max_depth,
243
+ )
244
+ nil
245
+ else
246
+ raise
247
+ end
248
+ end
249
+
250
+ # Walk upward from a starting frontier of {Parse::Role} objects
251
+ # through the +_Role.roles+ inverse relation, collecting every
252
+ # role name reachable. Used by {.all_for_user} (frontier = the
253
+ # user's direct roles) and {Parse::Role#all_parent_role_names}
254
+ # (frontier = the role itself).
255
+ #
256
+ # The starting frontier is INCLUDED in the returned set, because
257
+ # the semantics is "every role name whose presence in +_rperm+
258
+ # grants access" — direct membership counts.
259
+ #
260
+ # @param starting_roles [Array<Parse::Role>] roles to begin the
261
+ # upward traversal from.
262
+ # @param max_depth [Integer] maximum BFS depth.
263
+ # @return [Set<String>] role names (no +role:+ prefix) including
264
+ # the starting frontier and every transitive parent.
265
+ def expand_inheritance_upward(starting_roles, max_depth: 10)
266
+ names = Set.new
267
+ visited_ids = Set.new
268
+ frontier = []
269
+
270
+ Array(starting_roles).each do |role|
271
+ next if role.nil? || role.id.nil?
272
+ next if visited_ids.include?(role.id)
273
+ visited_ids << role.id
274
+ names << role.name if role.respond_to?(:name) && role.name.present?
275
+ frontier << role
276
+ end
277
+
278
+ depth = 0
279
+ while frontier.any? && depth < max_depth
280
+ next_frontier = []
281
+ frontier.each do |role|
282
+ next if role.nil? || role.id.nil?
283
+ begin
284
+ parents = Parse::Role.all(roles: role)
285
+ rescue
286
+ next
287
+ end
288
+ parents.each do |parent|
289
+ next if parent.nil? || parent.id.nil?
290
+ next if visited_ids.include?(parent.id)
291
+ visited_ids << parent.id
292
+ names << parent.name if parent.respond_to?(:name) && parent.name.present?
293
+ next_frontier << parent
294
+ end
295
+ end
296
+ frontier = next_frontier
297
+ depth += 1
298
+ end
299
+
300
+ names
301
+ end
302
+
303
+ private
304
+
305
+ # @!visibility private
306
+ # Coerce caller-supplied user argument into a +Parse::Pointer+
307
+ # on +_User+ suitable for an inverse-relation query. Returns
308
+ # +nil+ when the input cannot be resolved to a +_User+ id, in
309
+ # which case {.all_for_user} returns an empty set without
310
+ # issuing a network call.
311
+ def role_lookup_pointer_for(user)
312
+ if user.is_a?(Parse::User)
313
+ return nil unless user.id.present?
314
+ user
315
+ elsif user.is_a?(Parse::Pointer)
316
+ klass = user.parse_class
317
+ return nil unless klass == Parse::Model::CLASS_USER || klass == "User"
318
+ return nil unless user.id.present?
319
+ user
320
+ elsif user.is_a?(String) && !user.strip.empty?
321
+ Parse::Pointer.new(Parse::Model::CLASS_USER, user)
322
+ else
323
+ nil
324
+ end
325
+ end
326
+ end
327
+
328
+ # Add a single user to this role.
329
+ # @param user [Parse::User] the user to add.
330
+ # @return [self] returns self for chaining.
331
+ # @example
332
+ # role.add_user(user).save
333
+ def add_user(user)
334
+ users.add(user)
335
+ self
336
+ end
337
+
338
+ # Add multiple users to this role.
339
+ # @param user_list [Array<Parse::User>] users to add.
340
+ # @return [self] returns self for chaining.
341
+ # @example
342
+ # role.add_users(user1, user2, user3).save
343
+ def add_users(*user_list)
344
+ users.add(user_list.flatten)
345
+ self
346
+ end
347
+
348
+ # Remove a single user from this role.
349
+ # @param user [Parse::User] the user to remove.
350
+ # @return [self] returns self for chaining.
351
+ def remove_user(user)
352
+ users.remove(user)
353
+ self
354
+ end
355
+
356
+ # Remove multiple users from this role.
357
+ # @param user_list [Array<Parse::User>] users to remove.
358
+ # @return [self] returns self for chaining.
359
+ def remove_users(*user_list)
360
+ users.remove(user_list.flatten)
361
+ self
362
+ end
363
+
364
+ # Add a child role to this role's hierarchy.
365
+ #
366
+ # **The method name is misleading — prefer {#grant_capabilities_to!}
367
+ # or {#inherits_capabilities_from!}.** `add_child_role` mutates the
368
+ # receiver's `roles` relation; per Parse Server semantics, putting
369
+ # role Y in role X's `roles` relation grants X's capabilities to
370
+ # users-of-Y. The "child" terminology has the inheritance direction
371
+ # exactly inverted from intuitive org-chart reading. Retained for
372
+ # backward compatibility and as the low-level structural primitive;
373
+ # new callers should use the direction-explicit semantic methods.
374
+ #
375
+ # IMPORTANT — Parse Server _Role inheritance semantics: when role X
376
+ # holds role Y in its `roles` relation, **users of Y inherit X's
377
+ # permissions** (not the other way around). So calling
378
+ # +admin.add_child_role(moderator)+ does NOT grant Moderator's
379
+ # capabilities to Admin; it grants Admin's capabilities to every
380
+ # Moderator user — privilege escalation.
381
+ #
382
+ # If you want Admins to have everything Moderators can do, you need
383
+ # to add ADMIN to MODERATOR's roles relation:
384
+ #
385
+ # moderator.add_child_role(admin) # Admins now have Moderator capabilities
386
+ #
387
+ # Direction-explicit replacements:
388
+ #
389
+ # admin.inherits_capabilities_from!(moderator) # admin perspective
390
+ # moderator.grant_capabilities_to!(admin) # moderator perspective
391
+ #
392
+ # Both bang variants auto-save and return self.
393
+ #
394
+ # @param role [Parse::Role] the role to add to this role's `roles` relation.
395
+ # @return [self] returns self for chaining.
396
+ # @raise [ArgumentError] when +role+ is +self+ (a self-loop in the `_Role.roles` relation produces an infinite recursion on lookup and serves no permission purpose).
397
+ def add_child_role(role)
398
+ assert_not_self_reference!(role, :add_child_role)
399
+ roles.add(role)
400
+ self
401
+ end
402
+
403
+ # Add multiple child roles to this role's hierarchy. See
404
+ # {#add_child_role} for the inheritance-direction caveat.
405
+ # @param role_list [Array<Parse::Role>] roles to add.
406
+ # @return [self] returns self for chaining.
407
+ # @raise [ArgumentError] when any entry in +role_list+ is +self+.
408
+ def add_child_roles(*role_list)
409
+ flat = role_list.flatten
410
+ flat.each { |r| assert_not_self_reference!(r, :add_child_roles) }
411
+ roles.add(flat)
412
+ self
413
+ end
414
+
415
+ # Remove a child role from this role's hierarchy.
416
+ # @param role [Parse::Role] the child role to remove.
417
+ # @return [self] returns self for chaining.
418
+ def remove_child_role(role)
419
+ roles.remove(role)
420
+ self
421
+ end
422
+
423
+ # Remove multiple child roles from this role's hierarchy.
424
+ # @param role_list [Array<Parse::Role>] child roles to remove.
425
+ # @return [self] returns self for chaining.
426
+ def remove_child_roles(*role_list)
427
+ roles.remove(role_list.flatten)
428
+ self
429
+ end
430
+
431
+ # Grant this role's capabilities to the given role's users. Reads as:
432
+ # "users with +grantee+ now have +self+'s capabilities."
433
+ # Equivalent to +self.add_child_role(grantee)+ but unambiguous about
434
+ # the direction of inheritance.
435
+ #
436
+ # Non-saving — the caller must call +self.save+ to persist. See
437
+ # {#grant_capabilities_to!} for the auto-saving variant.
438
+ #
439
+ # @param grantee [Parse::Role] the role whose users will inherit this role's permissions.
440
+ # @return [self] returns self for chaining.
441
+ # @example
442
+ # moderator.grant_capabilities_to(admin).save
443
+ # # → Admin users can now do anything Moderator users can
444
+ def grant_capabilities_to(grantee)
445
+ assert_not_self_reference!(grantee, :grant_capabilities_to)
446
+ roles.add(grantee)
447
+ self
448
+ end
449
+
450
+ # Auto-saving variant of {#grant_capabilities_to}. Performs the
451
+ # relation mutation AND persists +self+ in one call. Returns +self+
452
+ # consistently so the caller can chain or store the result without
453
+ # tracking which object was mutated. Prefer this in tests and
454
+ # one-shot scripts where batching multiple mutations isn't needed.
455
+ #
456
+ # @param grantee [Parse::Role] the role whose users will inherit this role's permissions.
457
+ # @return [self] the mutated and persisted self.
458
+ # @raise [Parse::RecordNotSaved] if the save fails.
459
+ # @example
460
+ # moderator.grant_capabilities_to!(admin)
461
+ # # → Admin users can now do anything Moderator users can. Persisted.
462
+ def grant_capabilities_to!(grantee)
463
+ grant_capabilities_to(grantee)
464
+ save!
465
+ self
466
+ end
467
+
468
+ # Inverse spelling of {#grant_capabilities_to}: "this role's users
469
+ # inherit +source+'s capabilities". Performs the relation mutation
470
+ # on +source+, not on +self+.
471
+ #
472
+ # **Save target.** The mutation lives on +source.roles+. To persist,
473
+ # the caller must save +source+, NOT +self+. This asymmetry exists
474
+ # because Parse Server stores the relation on the role that holds
475
+ # the +roles+ list, and that role is +source+. The non-bang form is
476
+ # retained for callers that need to batch multiple mutations on
477
+ # +source+ before a single save; prefer {#inherits_capabilities_from!}
478
+ # for the one-shot case where the auto-save matches intent.
479
+ #
480
+ # @param source [Parse::Role] the role whose capabilities this role's users acquire.
481
+ # @return [Parse::Role] the +source+ role (caller still needs to .save it
482
+ # if not using the bang variant).
483
+ # @example Non-saving (must save source separately)
484
+ # admin.inherits_capabilities_from(moderator)
485
+ # moderator.save
486
+ # @example Auto-saving via the bang variant
487
+ # admin.inherits_capabilities_from!(moderator)
488
+ # # → Admin users can now do anything Moderator users can. Persisted.
489
+ def inherits_capabilities_from(source)
490
+ assert_not_self_reference!(source, :inherits_capabilities_from)
491
+ source.roles.add(self)
492
+ source
493
+ end
494
+
495
+ # Auto-saving variant of {#inherits_capabilities_from}. Performs the
496
+ # mutation on +source.roles+ AND saves +source+ for you, then
497
+ # returns +self+ so the caller can keep working with the role they
498
+ # called the method on. Resolves the most common stumbling block
499
+ # with {#inherits_capabilities_from}: the "save target" asymmetry.
500
+ #
501
+ # @param source [Parse::Role] the role whose capabilities this role's users acquire.
502
+ # @return [self] the role that now inherits (caller's original receiver).
503
+ # @raise [Parse::RecordNotSaved] if the save of +source+ fails.
504
+ # @example
505
+ # admin.inherits_capabilities_from!(moderator)
506
+ # # → Admin users can now do anything Moderator users can. Persisted.
507
+ def inherits_capabilities_from!(source)
508
+ inherits_capabilities_from(source)
509
+ source.save!
510
+ self
511
+ end
512
+
513
+ # Check if a user belongs to this role (direct membership only).
514
+ # @param user [Parse::User] the user to check.
515
+ # @return [Boolean] true if user is a direct member.
516
+ def has_user?(user)
517
+ return false unless user.is_a?(Parse::User) && user.id.present?
518
+ users.query.where(objectId: user.id).count > 0
519
+ end
520
+
521
+ # Check if a role is a direct child of this role.
522
+ # @param role [Parse::Role] the role to check.
523
+ # @return [Boolean] true if role is a direct child.
524
+ def has_child_role?(role)
525
+ return false unless role.is_a?(Parse::Role) && role.id.present?
526
+ roles.query.where(objectId: role.id).count > 0
527
+ end
528
+
529
+ # Get all users belonging to this role, including users from child roles recursively.
530
+ #
531
+ # Cycle-safe: a +visited+ set guards against pathological
532
+ # +_Role.roles+ cycles (e.g. A→B→A) that would otherwise cause
533
+ # exponential per-node query fan-out.
534
+ #
535
+ # @param max_depth [Integer] maximum recursion depth.
536
+ # @param visited [Set] internal cycle-detection accumulator.
537
+ # @param master [Boolean] when +true+, opt in to the mongo-direct
538
+ # fast path under master-mode. The follow-up `_User` fetch also
539
+ # runs unscoped — used for admin/analytics paths that need a
540
+ # master-key view of every member.
541
+ # @param as [Parse::User, Parse::Pointer, nil] when supplied, opt
542
+ # in to the mongo-direct fast path under the caller's scope. The
543
+ # `_Role` CLP is checked on entry, the `_User` `_rperm` allow-set
544
+ # is folded into the join sub-pipeline (so the fast path returns
545
+ # only members the scope is allowed to read), and the follow-up
546
+ # `Parse::MongoDB.aggregate` call to hydrate the user rows runs
547
+ # under the same scope (so `_User` ACL fires for both the join
548
+ # filter AND the post-fetch hydration).
549
+ # @return [Array<Parse::User>] all users in the role hierarchy.
550
+ # @note When neither `master:` nor `as:` is supplied, the
551
+ # mongo-direct fast path is skipped; the method falls through
552
+ # to the Parse-Server walk through the per-relation query
553
+ # interface, which goes through the default Parse::Client.
554
+ # @example
555
+ # all_users = admin_role.all_users(master: true)
556
+ # visible = admin_role.all_users(as: current_user)
557
+ def all_users(max_depth: 10, visited: Set.new, master: false, as: nil)
558
+ return [] if max_depth <= 0
559
+ return [] if id.nil? || visited.include?(id)
560
+
561
+ # The fast path is opt-in (same rationale as {.all_for_user}).
562
+ if master == true || !as.nil?
563
+ fast_path = all_users_mongo_fast_path(max_depth, master: master, as: as)
564
+ if fast_path.is_a?(Array)
565
+ ActiveSupport::Notifications.instrument(
566
+ "parse.role.expand",
567
+ direction: :reverse, target_id: id, depth: max_depth,
568
+ source: :mongo_direct, result_count: fast_path.size,
569
+ )
570
+ return fast_path
571
+ end
572
+ end
573
+
574
+ visited << id
575
+
576
+ direct_users = users.all
577
+
578
+ child_roles = roles.all
579
+ child_users = child_roles.flat_map do |child_role|
580
+ child_role.all_users(max_depth: max_depth - 1, visited: visited)
581
+ end
582
+
583
+ result = (direct_users + child_users).uniq { |u| u.id }
584
+ ActiveSupport::Notifications.instrument(
585
+ "parse.role.expand",
586
+ direction: :reverse, target_id: id, depth: max_depth,
587
+ source: :parse_server, result_count: result.size,
588
+ )
589
+ result
590
+ end
591
+
592
+ # @!visibility private
593
+ # Try the mongo-direct fast path for {#all_users}. Returns an Array
594
+ # of hydrated {Parse::User} objects on success, or `nil` when the
595
+ # fast path is unavailable. Attack-signal errors propagate.
596
+ #
597
+ # When `master: true`, the `_User` follow-up fetch runs through
598
+ # `Parse::User.all` (default client, master-key). When `as: <user>`
599
+ # is supplied, the follow-up runs through `Parse::MongoDB.aggregate`
600
+ # with the caller scope so `_User` ACL fires both server-side
601
+ # (sub-pipeline `_rperm` match in the role-subtree join, see
602
+ # MONGO-4) AND on the hydration query (full _User row-level ACL
603
+ # filtering before the rows hit the wire).
604
+ def all_users_mongo_fast_path(max_depth, master: false, as: nil)
605
+ return nil unless defined?(Parse::MongoDB)
606
+ return nil unless Parse::MongoDB.respond_to?(:users_in_role_subtree)
607
+ ids = Parse::MongoDB.users_in_role_subtree(
608
+ id, max_depth: max_depth, master: master, as: as,
609
+ )
610
+ return nil if ids.nil?
611
+ return [] if ids.empty?
612
+
613
+ if master == true
614
+ # Master path: master-keyed default client returns every row.
615
+ Parse::User.all(:objectId.in => ids.to_a)
616
+ else
617
+ # Scoped path: route through Parse::MongoDB.aggregate so _User
618
+ # ACL is enforced by the SDK on the hydration query. The
619
+ # aggregate already strips protectedFields and filters by
620
+ # _rperm/CLP under the resolved scope.
621
+ hydrate_users_under_scope(ids.to_a, as)
622
+ end
623
+ rescue StandardError => e
624
+ if defined?(::Mongo::Error::ConnectionFailure) &&
625
+ e.is_a?(::Mongo::Error::ConnectionFailure)
626
+ # Emit a structured event so operators can monitor fast-path
627
+ # availability separate from the role-graph notification.
628
+ ActiveSupport::Notifications.instrument(
629
+ "parse.role.fast_path_unavailable",
630
+ reason: "connection_failure", direction: :reverse,
631
+ target_id: id, depth: max_depth,
632
+ )
633
+ nil
634
+ else
635
+ raise
636
+ end
637
+ end
638
+
639
+ # @!visibility private
640
+ # Hydrate a list of `_User.objectId`s into {Parse::User} instances
641
+ # via `Parse::MongoDB.aggregate` under the supplied scope. This is
642
+ # the scoped-path hydration that goes through the SDK's ACL
643
+ # enforcement (top-level _rperm match, CLP, protectedFields strip)
644
+ # instead of the master-keyed `Parse::User.all`.
645
+ #
646
+ # Returns an Array of {Parse::User} instances (possibly empty).
647
+ def hydrate_users_under_scope(ids, as_scope)
648
+ return [] if ids.nil? || ids.empty?
649
+ pipeline = [
650
+ { "$match" => { "_id" => { "$in" => ids.map(&:to_s) } } },
651
+ ]
652
+ raw = Parse::MongoDB.aggregate(
653
+ Parse::Model::CLASS_USER, pipeline,
654
+ allow_internal_fields: true, acl_user: as_scope,
655
+ )
656
+ raw.map do |doc|
657
+ parse_doc = Parse::MongoDB.convert_document_to_parse(
658
+ doc, Parse::Model::CLASS_USER,
659
+ )
660
+ Parse::User.new(parse_doc) if parse_doc
661
+ end.compact
662
+ end
663
+ private :hydrate_users_under_scope
664
+
665
+ # Get the set of role names whose presence in a +_rperm+ array
666
+ # grants access to this role's members. That's the role itself
667
+ # plus every role +P+ that lists this role in its +roles+ relation,
668
+ # transitively upward — because users of this role inherit +P+'s
669
+ # permissions under Parse Server's role-inheritance semantics
670
+ # (see {#add_child_role}).
671
+ #
672
+ # The instance-side analogue to {Parse::Role.all_for_user}; the
673
+ # two share an internal BFS via
674
+ # {Parse::Role.expand_inheritance_upward}. Use this method when
675
+ # compiling an ACL predicate around a role argument, e.g.
676
+ # +:ACL.readable_by => admin_role+: the role itself contributes
677
+ # +"role:Admin"+, and any role whose +.roles+ relation contains
678
+ # +admin_role+ also grants Admins access through inheritance.
679
+ #
680
+ # The legacy {#all_child_roles} walk is NOT a substitute. Child
681
+ # roles inherit FROM this role (their members get this role's
682
+ # capabilities), so child-role names in +_rperm+ would not grant
683
+ # this role's members anything — the walk traverses the wrong
684
+ # direction for ACL composition.
685
+ #
686
+ # @param max_depth [Integer] maximum BFS depth (default: 10).
687
+ # @return [Set<String>] role names (no +role:+ prefix) including
688
+ # +self.name+ and every transitive parent.
689
+ # @example
690
+ # permission_strings = admin.all_parent_role_names.map { |n| "role:#{n}" }
691
+ def all_parent_role_names(max_depth: 10)
692
+ Parse::Role.expand_inheritance_upward([self], max_depth: max_depth)
693
+ end
694
+
695
+ # Get all child roles recursively. Cycle-safe; see {#all_users}.
696
+ # @param max_depth [Integer] maximum recursion depth.
697
+ # @param visited [Set] internal cycle-detection accumulator.
698
+ # @return [Array<Parse::Role>] all child roles in the hierarchy.
699
+ def all_child_roles(max_depth: 10, visited: Set.new)
700
+ return [] if max_depth <= 0
701
+ return [] if id.nil? || visited.include?(id)
702
+ visited << id
703
+
704
+ direct_children = roles.all
705
+ nested_children = direct_children.flat_map do |child|
706
+ child.all_child_roles(max_depth: max_depth - 1, visited: visited)
707
+ end
708
+
709
+ (direct_children + nested_children).uniq { |r| r.id }
710
+ end
711
+
712
+ # Get the count of direct users in this role.
713
+ # @return [Integer] number of direct users.
714
+ def users_count
715
+ users.query.count
716
+ end
717
+
718
+ # Get the count of direct child roles.
719
+ # @return [Integer] number of direct child roles.
720
+ def child_roles_count
721
+ roles.query.count
722
+ end
723
+
724
+ # Get the total count of users including child roles.
725
+ # @return [Integer] total user count in hierarchy.
726
+ def total_users_count
727
+ all_users.count
728
+ end
729
+
730
+ private
731
+
732
+ # @!visibility private
733
+ # Refuses a `_Role.roles` mutation that would point a role at itself.
734
+ # The visited-Set guard in {#all_users} / {#all_child_roles} prevents
735
+ # the recursion blowup at read time, but a persisted self-loop still
736
+ # wastes one round-trip per traversal and has no permission effect
737
+ # under Parse Server's role-expansion rules. Reject at write time.
738
+ def assert_not_self_reference!(role, method_name)
739
+ raise ArgumentError,
740
+ "#{method_name} requires a Parse::Role argument (got #{role.class})" unless role.is_a?(Parse::Role)
741
+ same_instance = role.equal?(self)
742
+ same_id = id.present? && role.id.present? && role.id == id
743
+ if same_instance || same_id
744
+ raise ArgumentError,
745
+ "#{method_name} cannot point a role at itself " \
746
+ "(role #{name.inspect}/#{id.inspect}); self-loops in the " \
747
+ "_Role.roles relation serve no permission purpose."
748
+ end
749
+ end
750
+ end
751
+ end