parse-stack-next 4.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,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
|