parse-stack-next 5.8.1 → 5.8.2

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.
@@ -89,6 +89,23 @@ module Parse
89
89
  end
90
90
  end
91
91
 
92
+ # Raised when a session-token agent needs its resolved claim set (for
93
+ # the SDK-side CLP check, an ACL `$match`, or a sub-agent subset check)
94
+ # but the token could not be resolved. A session agent is never treated
95
+ # as master-key posture just because its token did not resolve: the
96
+ # check fails closed instead. A subclass of {AccessDenied}, so tool
97
+ # dispatch reports it as an `:access_denied` refusal
98
+ # (`kind: :unresolved_identity`).
99
+ class UnresolvedIdentity < AccessDenied
100
+ def initialize(message = nil)
101
+ super(nil, message || "This agent's session could not be resolved, so its " \
102
+ "permissions cannot be checked. The request was refused " \
103
+ "rather than run without them; retry once Parse Server is " \
104
+ "reachable, or rebuild the agent with a valid session token.",
105
+ kind: :unresolved_identity)
106
+ end
107
+ end
108
+
92
109
  # Authentication failure for MCP transport adapters. Custom auth blocks
93
110
  # passed to Parse::Agent::MCPRackApp should raise this (or a subclass) to
94
111
  # signal an unauthenticated/unauthorized request; the transport layer
@@ -115,6 +115,32 @@ module Parse
115
115
  # Loopback hosts that are safe to bind to without an API key.
116
116
  LOOPBACK_HOSTS = %w[127.0.0.1 ::1 localhost].freeze
117
117
 
118
+ # Shortest API key accepted for a non-loopback bind.
119
+ MIN_PUBLIC_API_KEY_LENGTH = 16
120
+
121
+ # The API key to enforce, or nil when none is configured. Surrounding
122
+ # whitespace (ASCII or Unicode) is stripped, and a blank result is no
123
+ # key.
124
+ # @param key [String, nil]
125
+ # @return [String, nil]
126
+ # Strip leading and trailing whitespace of any kind (ASCII or Unicode)
127
+ # with a linear scan from each end.
128
+ # @param str [String]
129
+ # @return [String]
130
+ def self.strip_unicode_space(str)
131
+ first = 0
132
+ last = str.length
133
+ first += 1 while first < last && str[first].match?(/[[:space:]]/)
134
+ last -= 1 while last > first && str[last - 1].match?(/[[:space:]]/)
135
+ str[first...last]
136
+ end
137
+
138
+ def self.normalize_api_key(key)
139
+ return nil if key.nil?
140
+ stripped = strip_unicode_space(key.to_s)
141
+ stripped.empty? ? nil : stripped
142
+ end
143
+
118
144
  def initialize(port: 3001, host: "127.0.0.1", permissions: :readonly,
119
145
  session_token: nil, api_key: nil, rate_limiter: nil,
120
146
  pre_auth_rate_limiter: nil,
@@ -126,7 +152,15 @@ module Parse
126
152
  raise ArgumentError, "pre_auth_rate_limiter must respond to #check!"
127
153
  end
128
154
 
129
- effective_api_key = api_key || ENV["MCP_API_KEY"]
155
+ # One normalization for every check below: surrounding whitespace is
156
+ # stripped and a key that is empty after stripping counts as no key.
157
+ # A whitespace-only key used to pass the non-loopback bind check
158
+ # (`" ".to_s.empty?` is false) and then disable request auth
159
+ # (`" ".present?` is false), leaving a public endpoint open.
160
+ # An explicit blank `api_key:` does not hide a configured
161
+ # MCP_API_KEY.
162
+ effective_api_key = self.class.normalize_api_key(api_key) ||
163
+ self.class.normalize_api_key(ENV["MCP_API_KEY"])
130
164
 
131
165
  # NEW-MCP-1: a non-loopback bind without an API key is an unauthenticated
132
166
  # network-exposed JSON-RPC endpoint. Refuse to start. Operators who
@@ -134,12 +168,19 @@ module Parse
134
168
  # auth — should bind to localhost and let the proxy forward, or
135
169
  # set MCP_API_KEY explicitly even when "the proxy authenticates"
136
170
  # (defense in depth).
137
- if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.to_s.empty?
171
+ if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.nil?
138
172
  raise ArgumentError,
139
173
  "MCPServer refuses to bind non-loopback host #{host.inspect} without an api_key. " \
140
174
  "Set MCP_API_KEY in the environment, pass api_key: explicitly, or use a loopback " \
141
175
  "host (one of: #{LOOPBACK_HOSTS.join(", ")})."
142
176
  end
177
+ # A short key on a public bind is guessable. Warn rather than refuse
178
+ # in a patch release so existing deployments keep starting.
179
+ if !LOOPBACK_HOSTS.include?(host.to_s) && effective_api_key.length < MIN_PUBLIC_API_KEY_LENGTH
180
+ warn "[Parse::Agent::MCPServer:SECURITY] api_key for non-loopback host #{host.inspect} " \
181
+ "is shorter than #{MIN_PUBLIC_API_KEY_LENGTH} characters. Use a long random key " \
182
+ "(for example SecureRandom.hex(32))."
183
+ end
143
184
 
144
185
  @port = port
145
186
  @host = host
@@ -250,8 +291,8 @@ module Parse
250
291
 
251
292
  # Tool list endpoint (requires auth if API key is configured)
252
293
  @server.mount_proc("/tools") do |req, res|
253
- if @api_key.present?
254
- provided_key = req[MCP_API_KEY_HEADER].to_s
294
+ if @api_key
295
+ provided_key = self.class.normalize_api_key(req[MCP_API_KEY_HEADER]).to_s
255
296
  unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
256
297
  error_response(res, 401, "Unauthorized: invalid or missing API key")
257
298
  next
@@ -353,8 +394,8 @@ module Parse
353
394
  # counters on each returned agent are scoped to that single request
354
395
  # and discarded when it ends, eliminating cross-request leakage.
355
396
  def agent_factory(env)
356
- if @api_key.present?
357
- provided_key = env["HTTP_X_MCP_API_KEY"].to_s
397
+ if @api_key
398
+ provided_key = self.class.normalize_api_key(env["HTTP_X_MCP_API_KEY"]).to_s
358
399
  unless ActiveSupport::SecurityUtils.secure_compare(@api_key, provided_key)
359
400
  raise Parse::Agent::Unauthorized.new("invalid or missing API key", reason: :bad_api_key)
360
401
  end
data/lib/parse/agent.rb CHANGED
@@ -3,8 +3,10 @@
3
3
 
4
4
  require "active_support/notifications"
5
5
  require "securerandom"
6
+ require "digest"
6
7
  require "set"
7
8
  require "uri"
9
+ require "monitor"
8
10
  require_relative "mongodb"
9
11
  require_relative "acl_scope"
10
12
  require_relative "model/acl"
@@ -906,12 +908,45 @@ module Parse
906
908
  attr_reader :acl_role_scope
907
909
 
908
910
  # @return [Parse::ACLScope::Resolution, nil] the resolved ACL scope
909
- # for this agent. Frozen at construction. `nil` means master-key
910
- # posture — the agent runs every tool call with the application
911
- # master key, bypassing per-row ACL/CLP enforcement. Non-nil
912
- # carries a `permission_strings` allow-set that built-in tools
913
- # forward to mongo-direct / Atlas Search via {#acl_scope_kwargs}.
914
- attr_reader :acl_scope
911
+ # for this agent. `nil` means master-key posture: the agent runs
912
+ # every tool call with the application master key, bypassing per-row
913
+ # ACL/CLP enforcement. Non-nil carries a `permission_strings`
914
+ # allow-set that built-in tools forward to mongo-direct / Atlas
915
+ # Search via {#acl_scope_kwargs}.
916
+ #
917
+ # A session-token agent is resolved eagerly at construction. When
918
+ # that fails (Parse Server unreachable), or after {#impersonate}, the
919
+ # scope is resolved again here on first use. It never reads as `nil`
920
+ # for a session agent: if the token still cannot be resolved this
921
+ # raises {Parse::Agent::UnresolvedIdentity} rather than letting a
922
+ # caller mistake the agent for master-key posture.
923
+ # @raise [Parse::Agent::UnresolvedIdentity]
924
+ def acl_scope
925
+ # The unresolved check and the read happen under one lock, so a
926
+ # concurrent #impersonate cannot clear the scope between them and
927
+ # hand a session agent a nil (master-looking) scope. Resolution itself
928
+ # runs outside the lock (see #ensure_session_scope!).
929
+ resolved = scope_mutex.synchronize do
930
+ session_scope_unresolved? ? :unresolved : @acl_scope
931
+ end
932
+ resolved == :unresolved ? ensure_session_scope! : resolved
933
+ end
934
+
935
+ # @return [Boolean] `true` when the agent has a session token whose
936
+ # claim set has not been resolved yet (a failed eager resolution at
937
+ # construction, or a fresh {#impersonate}).
938
+ def session_scope_unresolved?
939
+ @acl_scope.nil? && !@session_token.to_s.empty?
940
+ end
941
+
942
+ # The session token and its resolved scope read together under the scope
943
+ # lock, so a sub-agent never pairs one token with another token's scope.
944
+ # @return [Array(String, Parse::ACLScope::Resolution)] `[token, scope]`;
945
+ # either may be nil.
946
+ def scope_snapshot
947
+ scope_mutex.synchronize { [@session_token, @acl_scope] }
948
+ end
949
+ protected :scope_snapshot
915
950
 
916
951
  # @return [Boolean] whether this agent may run Atlas Search tools
917
952
  # in master-key-equivalent mode when no `session_token` is set.
@@ -1169,7 +1204,11 @@ module Parse
1169
1204
  #
1170
1205
  # @return [Array<String>, nil]
1171
1206
  def acl_permission_strings
1172
- @acl_scope&.permission_strings
1207
+ scope = acl_scope
1208
+ # A session agent never reads as master-key posture, even if a scope
1209
+ # was cleared by something other than #impersonate.
1210
+ raise unresolved_identity_error if scope.nil? && !@session_token.to_s.empty?
1211
+ scope&.permission_strings
1173
1212
  end
1174
1213
 
1175
1214
  # A ready-to-prepend `$match` stage filtering an aggregation
@@ -1201,7 +1240,8 @@ module Parse
1201
1240
  end
1202
1241
 
1203
1242
  # `true` when the agent carries any non-master-key scope
1204
- # (session_token, acl_user, or acl_role). Use this when deciding
1243
+ # (session_token, acl_user, or acl_role), resolved or not. Use this
1244
+ # when deciding
1205
1245
  # whether a Parse Server endpoint that DOES NOT enforce ACL
1206
1246
  # (notably the REST `aggregate` endpoint) is safe to route through:
1207
1247
  # any `true` here means the REST path would silently bypass the
@@ -1210,7 +1250,7 @@ module Parse
1210
1250
  #
1211
1251
  # @return [Boolean]
1212
1252
  def acl_scope?
1213
- !@acl_scope.nil?
1253
+ !@acl_scope.nil? || !@session_token.to_s.empty?
1214
1254
  end
1215
1255
 
1216
1256
  # `true` when the agent's ACL scope cannot be honored by Parse
@@ -1261,16 +1301,25 @@ module Parse
1261
1301
  #
1262
1302
  # @return [Parse::ACLScope::Resolution, nil]
1263
1303
  def refresh_scope!
1264
- return @acl_scope if @session_token
1265
- return nil if @acl_user_scope.nil? && @acl_role_scope.nil?
1266
- resolved = if @acl_user_scope
1267
- Parse::ACLScope.resolve_for_user(@acl_user_scope, client: @client)
1304
+ # A session agent resolves (or raises) rather than returning nil,
1305
+ # which callers read as master-key posture.
1306
+ return acl_scope if @session_token
1307
+ user_scope, role_scope = scope_mutex.synchronize { [@acl_user_scope, @acl_role_scope] }
1308
+ return nil if user_scope.nil? && role_scope.nil?
1309
+ resolved = if user_scope
1310
+ Parse::ACLScope.resolve_for_user(user_scope, client: @client)
1268
1311
  else
1269
- Parse::ACLScope.resolve_for_role(@acl_role_scope, client: @client)
1312
+ Parse::ACLScope.resolve_for_role(role_scope, client: @client)
1270
1313
  end
1271
- @acl_scope = resolved&.freeze
1272
- @auth_context = nil # invalidate memoized auth_context — user_id may have changed
1273
- @acl_scope
1314
+ scope_mutex.synchronize do
1315
+ # Bind only if the identity did not change while resolving (a
1316
+ # concurrent #impersonate replaces it).
1317
+ if @session_token.nil? && @acl_user_scope.equal?(user_scope) && @acl_role_scope.equal?(role_scope)
1318
+ @acl_scope = resolved&.freeze
1319
+ @auth_context = nil # user_id may have changed
1320
+ end
1321
+ @acl_scope
1322
+ end
1274
1323
  end
1275
1324
 
1276
1325
  # @return [String, nil] free-form audit label attached to an
@@ -1293,30 +1342,42 @@ module Parse
1293
1342
  # @return [self]
1294
1343
  def impersonate(user, mint: false, label: nil)
1295
1344
  token = resolve_impersonation_token!(user, mint: mint)
1296
- @session_token = token
1297
- @acl_user_scope = nil
1298
- @acl_role_scope = nil
1299
- @impersonation_label = sanitize_impersonation_label(label) if label
1300
- # Drop memoized scope/auth so the next call resolves under the new
1301
- # token (session-token validity is checked per-call by Parse Server).
1302
- @acl_scope = nil
1303
- @auth_context = nil
1304
- no_master_key = @client.respond_to?(:master_key) && @client.master_key.nil?
1305
- @client_mode = no_master_key && !@session_token.to_s.empty?
1345
+ scope_mutex.synchronize do
1346
+ @session_token = token
1347
+ @acl_user_scope = nil
1348
+ @acl_role_scope = nil
1349
+ @impersonation_label = sanitize_impersonation_label(label) if label
1350
+ # Drop memoized scope/auth so the next call resolves under the new
1351
+ # token (session-token validity is checked per-call by Parse Server).
1352
+ @acl_scope = nil
1353
+ @auth_context = nil
1354
+ @scope_failed_at = nil
1355
+ @scope_failed_token = nil
1356
+ no_master_key = @client.respond_to?(:master_key) && @client.master_key.nil?
1357
+ @client_mode = no_master_key && !@session_token.to_s.empty?
1358
+ end
1306
1359
  self
1307
1360
  end
1308
1361
 
1309
- # Clear an impersonation binding established via {#impersonate},
1310
- # returning the agent to master-key posture. Does not revoke the
1311
- # underlying _Session row (the token may be shared/minted elsewhere).
1362
+ # Clear an impersonation binding established via {#impersonate} (or the
1363
+ # `impersonate_user:` constructor option), returning the agent to
1364
+ # master-key posture. Does not revoke the underlying _Session row (the
1365
+ # token may be shared/minted elsewhere). A no-op on an agent that is not
1366
+ # impersonating: an agent built with its own `session_token:` keeps it,
1367
+ # rather than silently gaining master-key posture.
1312
1368
  # @return [self]
1313
1369
  def stop_impersonating!
1314
- @session_token = nil
1315
- @impersonated_user_id = nil
1316
- @impersonation_label = nil
1317
- @acl_scope = nil
1318
- @auth_context = nil
1319
- @client_mode = false
1370
+ scope_mutex.synchronize do
1371
+ return self if @impersonated_user_id.nil?
1372
+ @session_token = nil
1373
+ @impersonated_user_id = nil
1374
+ @impersonation_label = nil
1375
+ @acl_scope = nil
1376
+ @auth_context = nil
1377
+ @scope_failed_at = nil
1378
+ @scope_failed_token = nil
1379
+ @client_mode = false
1380
+ end
1320
1381
  self
1321
1382
  end
1322
1383
 
@@ -1845,6 +1906,13 @@ module Parse
1845
1906
  @session_token = session_token
1846
1907
  @acl_user_scope = acl_user
1847
1908
  @acl_role_scope = acl_role
1909
+ # Guards the scope, the token, and the failed-resolution record, so a
1910
+ # concurrent #impersonate cannot leave one token's scope bound to
1911
+ # another, and concurrent first uses resolve only once.
1912
+ @scope_mutex = Monitor.new
1913
+ @scope_failed_at = nil
1914
+ @scope_failed_token = nil
1915
+ @scope_failure = nil
1848
1916
  @tenant_id = tenant_id
1849
1917
  @master_atlas = master_atlas == true
1850
1918
 
@@ -1938,18 +2006,24 @@ module Parse
1938
2006
  @acl_scope = if @session_token
1939
2007
  # Best-effort eager resolution. If Parse Server's /users/me is
1940
2008
  # unreachable at construction time (network blip, test env, MCP
1941
- # bootstrap-before-server-ready), leave @acl_scope nil and let
1942
- # Parse Server validate the token per-call via REST. The banner
1943
- # check below keys on identity inputs, NOT on resolution success,
1944
- # so an unresolved-but-supplied session_token does not trip the
1945
- # master-key banner. Failure is silent — Parse Server's
1946
- # per-call validation will surface auth errors at the
1947
- # actual usage site where the operator can act on them.
1948
- begin
1949
- opts = { session_token: @session_token, client: @client }.compact
1950
- Parse::ACLScope.resolve!(opts, method_name: :agent_init)
1951
- rescue StandardError
1952
- nil
2009
+ # bootstrap-before-server-ready), leave the scope unresolved:
2010
+ # {#acl_scope} retries on first use and raises
2011
+ # {Parse::Agent::UnresolvedIdentity} if it still fails, so an
2012
+ # unresolved session agent is never read as master-key posture.
2013
+ # Parse Server also validates the token on every REST call. The
2014
+ # banner check below keys on identity inputs, NOT on resolution
2015
+ # success, so an unresolved session_token does not trip the
2016
+ # master-key banner.
2017
+ #
2018
+ # A sub-agent holding its parent's own token on the parent's
2019
+ # client is the same identity: it reuses the parent's resolved
2020
+ # scope instead of a second /users/me call, and stays unresolved
2021
+ # (lazy path) when the parent has not resolved yet.
2022
+ parent_token, parent_scope = parent && @client.equal?(parent.client) ? parent.scope_snapshot : nil
2023
+ if parent_token && parent_token == @session_token
2024
+ parent_scope
2025
+ else
2026
+ eager_session_scope
1953
2027
  end
1954
2028
  elsif @acl_user_scope
1955
2029
  Parse::ACLScope.resolve_for_user(@acl_user_scope, client: @client)
@@ -1975,10 +2049,26 @@ module Parse
1975
2049
  # Same rationale.
1976
2050
  # * Parent has explicit permission_strings → child MUST have a
1977
2051
  # scope and child's permission_strings ⊆ parent's.
1978
- if parent && parent.acl_scope
1979
- parent_perms = parent.acl_scope.permission_strings
2052
+ # A parent whose session token has not resolved yet has an unknown
2053
+ # claim set. A child that inherits that same token is the same
2054
+ # identity and needs no check; any other child identity cannot be
2055
+ # proven a subset, so the parent's scope is resolved now (raising
2056
+ # Parse::Agent::UnresolvedIdentity if it still cannot be).
2057
+ parent_scope = if parent.nil?
2058
+ nil
2059
+ elsif parent.session_scope_unresolved? && @session_token == parent.session_token
2060
+ nil
2061
+ else
2062
+ parent.acl_scope
2063
+ end
2064
+ if parent_scope
2065
+ parent_perms = parent_scope.permission_strings
1980
2066
  if parent_perms && !parent_perms.empty?
1981
2067
  child_perms = @acl_scope&.permission_strings
2068
+ # A session token that did not resolve is not master-key posture:
2069
+ # its claim set is unknown, so the subset check cannot run. Report
2070
+ # it as unresolved rather than as an attempted widening.
2071
+ raise unresolved_identity_error if child_perms.nil? && session_scope_unresolved?
1982
2072
  if child_perms.nil?
1983
2073
  # SECURITY: emit the full diff on a dedicated audit
1984
2074
  # channel; redact identifiers from the user-visible
@@ -4139,6 +4229,13 @@ module Parse
4139
4229
  # acl_role, nil for master_key) so the AUDIT log can attribute
4140
4230
  # tool calls accurately.
4141
4231
  def auth_context
4232
+ # While the session is unresolved the identity is the token's
4233
+ # fingerprint (never the token), and the context is not memoized so
4234
+ # it picks up the user id once resolution succeeds.
4235
+ if session_scope_unresolved?
4236
+ return { type: :session_token, using_master_key: false,
4237
+ identity: "session:#{Digest::SHA256.hexdigest(@session_token.to_s)[0, 8]}" }
4238
+ end
4142
4239
  @auth_context ||= if @session_token && !@session_token.to_s.empty?
4143
4240
  { type: :session_token, using_master_key: false,
4144
4241
  identity: @acl_scope&.user_id }
@@ -4159,6 +4256,175 @@ module Parse
4159
4256
 
4160
4257
  private
4161
4258
 
4259
+ public
4260
+
4261
+ # A redacted one-line summary. Never includes the session token, the
4262
+ # client (whose inspect would print its keys), the conversation, or the
4263
+ # last request and response: an agent is often logged or reported by an
4264
+ # error tracker, and the default Ruby inspect dumped all of them.
4265
+ # @return [String]
4266
+ def inspect
4267
+ parts = ["id=#{agent_id}", "permissions=#{@permissions}", "auth=#{auth_context[:type]}"]
4268
+ parts << "scope=unresolved" if session_scope_unresolved?
4269
+ parts << "parent=#{parent_agent_id}" if parent_agent_id
4270
+ parts << "depth=#{agent_depth}" if agent_depth.to_i.positive?
4271
+ parts << "tools_only=#{@tool_filter_only.size}" if @tool_filter_only
4272
+ parts << "tools_except=#{@tool_filter_except.size}" if @tool_filter_except
4273
+ parts << "classes_only=#{@class_filter_only.size}" if @class_filter_only
4274
+ parts << "classes_except=#{@class_filter_except.size}" if @class_filter_except
4275
+ parts << "method_layers=#{method_filter_layers.size}" unless method_filter_layers.empty?
4276
+ parts << "client_mode" if @client_mode
4277
+ "#<Parse::Agent #{parts.join(" ")}>"
4278
+ end
4279
+ alias_method :to_s, :inspect
4280
+
4281
+ # `pp` walks instance variables by default; print the redacted summary.
4282
+ def pretty_print(q)
4283
+ q.text(inspect)
4284
+ end
4285
+
4286
+ # ActiveSupport's default `as_json` serializes every instance variable,
4287
+ # including the session token and the client (with its keys). JSON log
4288
+ # formatters and error trackers call it, so return the same redacted
4289
+ # fields as {#inspect}.
4290
+ # @return [Hash]
4291
+ def as_json(*)
4292
+ {
4293
+ "agent_id" => agent_id,
4294
+ "permissions" => @permissions.to_s,
4295
+ "auth" => auth_context[:type].to_s,
4296
+ "scope_unresolved" => session_scope_unresolved?,
4297
+ "parent_agent_id" => parent_agent_id,
4298
+ "depth" => agent_depth,
4299
+ "client_mode" => @client_mode ? true : false,
4300
+ }.compact
4301
+ end
4302
+
4303
+ # @return [String] the redacted {#as_json} summary as JSON.
4304
+ def to_json(*args)
4305
+ as_json.to_json(*args)
4306
+ end
4307
+
4308
+ # YAML (Psych) serializes instance variables too; emit the redacted
4309
+ # summary instead.
4310
+ def encode_with(coder)
4311
+ as_json.each { |k, v| coder[k] = v }
4312
+ end
4313
+
4314
+ # An agent holds a session token and a client with its keys, so it is
4315
+ # never marshaled. Build a new agent from configuration instead.
4316
+ # @raise [TypeError]
4317
+ def marshal_dump
4318
+ raise TypeError, "Parse::Agent cannot be marshaled"
4319
+ end
4320
+
4321
+ private
4322
+
4323
+ # Seconds a failed lazy resolution is remembered before the next tool
4324
+ # call tries again. Inside the window calls are refused at once instead
4325
+ # of each waiting out a request to an unreachable Parse Server.
4326
+ SCOPE_RETRY_BACKOFF = 5
4327
+
4328
+ # Resolve a session token into a frozen ACL scope. Does network I/O and
4329
+ # touches no agent state, so it is safe to call without the scope lock.
4330
+ # @return [Array(Parse::ACLScope::Resolution, Symbol)] `[scope, nil]` on
4331
+ # success, `[nil, :invalid_session]` when Parse Server rejected the
4332
+ # token, `[nil, :unreachable]` otherwise.
4333
+ def resolve_session_scope(token)
4334
+ opts = { session_token: token, client: @client }.compact
4335
+ [Parse::ACLScope.resolve!(opts, method_name: :agent_init)&.freeze, nil]
4336
+ rescue Parse::Authorization::InvalidSession => e
4337
+ [nil, e.message.to_s.include?("lookup failed") ? :unreachable : :invalid_session]
4338
+ rescue StandardError
4339
+ [nil, :unreachable]
4340
+ end
4341
+
4342
+ # Best-effort resolution at construction. A failure is recorded like a
4343
+ # lazy one, so the first use inside the backoff window is refused at once
4344
+ # instead of paying a second lookup.
4345
+ def eager_session_scope
4346
+ token = @session_token
4347
+ scope, failure = resolve_session_scope(token)
4348
+ record_scope_failure(token, failure) if scope.nil?
4349
+ scope
4350
+ end
4351
+
4352
+ # Resolve a session token whose eager resolution failed. Raises instead
4353
+ # of leaving the scope nil, which every caller reads as master.
4354
+ #
4355
+ # The token is read under the scope lock, the lookup runs without it (so
4356
+ # other threads using the agent are not blocked for a request timeout),
4357
+ # and the result is bound under the lock only if the token is unchanged,
4358
+ # so a concurrent #impersonate never ends up with this token's scope.
4359
+ def ensure_session_scope!
4360
+ # A concurrent #impersonate can change the token while a lookup runs.
4361
+ # The stale result is dropped and the new identity resolved; a few
4362
+ # rounds bound this if impersonate keeps racing.
4363
+ 3.times do
4364
+ token = scope_mutex.synchronize do
4365
+ return @acl_scope unless session_scope_unresolved?
4366
+ raise unresolved_identity_error if scope_failure_blocks?(@session_token)
4367
+ @session_token
4368
+ end
4369
+ resolved, failure = resolve_session_scope(token)
4370
+ bound = scope_mutex.synchronize do
4371
+ if @session_token != token
4372
+ :identity_changed
4373
+ elsif !@acl_scope.nil?
4374
+ # Another thread bound a scope for this token in the meantime.
4375
+ @acl_scope
4376
+ elsif resolved.nil?
4377
+ record_scope_failure(token, failure)
4378
+ raise unresolved_identity_error
4379
+ else
4380
+ @acl_scope = resolved
4381
+ @auth_context = nil
4382
+ @scope_failed_at = nil
4383
+ @scope_failed_token = nil
4384
+ @scope_failure = nil
4385
+ resolved
4386
+ end
4387
+ end
4388
+ return bound unless bound == :identity_changed
4389
+ end
4390
+ raise unresolved_identity_error
4391
+ end
4392
+
4393
+ def record_scope_failure(token, failure)
4394
+ scope_mutex.synchronize do
4395
+ @scope_failed_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
4396
+ @scope_failed_token = token
4397
+ @scope_failure = failure
4398
+ end
4399
+ end
4400
+
4401
+ # Whether a recorded failure for this token refuses the call without a
4402
+ # new lookup: always for a token Parse Server rejected (it will not
4403
+ # become valid), and within the backoff window for an unreachable server.
4404
+ def scope_failure_blocks?(token)
4405
+ return false if @scope_failed_at.nil? || @scope_failed_token != token
4406
+ return true if @scope_failure == :invalid_session
4407
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @scope_failed_at < SCOPE_RETRY_BACKOFF
4408
+ end
4409
+
4410
+ def unresolved_identity_error
4411
+ if @scope_failure == :invalid_session
4412
+ Parse::Agent::UnresolvedIdentity.new(
4413
+ "This agent's session token is invalid or expired, so its permissions " \
4414
+ "cannot be checked. The request was refused; rebuild the agent with a " \
4415
+ "valid session token.",
4416
+ )
4417
+ else
4418
+ Parse::Agent::UnresolvedIdentity.new
4419
+ end
4420
+ end
4421
+
4422
+ # Reentrant: #acl_scope holds it while #ensure_session_scope! takes it
4423
+ # again.
4424
+ def scope_mutex
4425
+ @scope_mutex ||= Monitor.new
4426
+ end
4427
+
4162
4428
  # Keys that should never be logged for security reasons.
4163
4429
  # Includes query-body keys (where, pipeline), credential keys (session_token,
4164
4430
  # password, secret, token, auth_data, authData, recovery_codes, api_key,
@@ -19,13 +19,81 @@ module Parse
19
19
  # @overload batch_request(operation)
20
20
  # Submit a batch operation.
21
21
  # @param operation [Parse::BatchOperation] the batch operation.
22
+ # @param opts [Hash] request options for the `POST /batch` call itself,
23
+ # such as `session_token:` or `use_master_key:`. Parse Server runs
24
+ # every sub-request under the credentials of this one call (it ignores
25
+ # per-sub-request headers), so the batch's authority is set here. A
26
+ # request that names no credentials of its own runs under these.
27
+ # Narrowing options always win: `use_master_key: false` here keeps
28
+ # the master key off even for a request that set
29
+ # `use_master_key: true`. An option here never widens a request.
30
+ # @note Every request is sent through this client, as
31
+ # {Parse::Client#request} sends a single request through the client
32
+ # it is called on, even when the request was built for another
33
+ # object's class client.
34
+ # @note Each {Parse::Request}'s own credentials (its `session_token:` /
35
+ # `use_master_key:` options and its session-token or master-key
36
+ # suppression headers) are honored.
37
+ # When every request resolves to this client and one set of
38
+ # credentials, the batch is one call. A transaction that would mix
39
+ # credentials raises {Parse::BatchOperation::MixedAuthorityError}
40
+ # before anything is sent. A non-transactional batch with mixed
41
+ # credentials is sent as one call per set of credentials, through
42
+ # {Parse::BatchOperation#submit}, with responses in request order.
43
+ # Elements that are not {Parse::Request} instances are ignored, as
44
+ # {Parse::BatchOperation#add} ignores them.
22
45
  # @return [Array<Parse::Response>] if successful, a set of responses for each operation in the batch.
23
46
  # @return [Parse::Response] if an error occurred, the error response.
24
- def batch_request(batch_operations)
47
+ # @raise [Parse::BatchOperation::MixedAuthorityError] for a transaction
48
+ # whose requests name different credentials.
49
+ def batch_request(batch_operations, **opts)
25
50
  unless batch_operations.is_a?(Parse::BatchOperation)
26
51
  batch_operations = Parse::BatchOperation.new batch_operations
27
52
  end
28
- response = request(:post, "batch", body: batch_operations.as_json)
53
+ call_auth = opts.slice(:session_token, :use_master_key, :suppress_master_key)
54
+ groups = batch_operations.send(:authority_groups, self, call_auth, force_client: true)
55
+ if groups.empty?
56
+ return post_batch_operation(batch_operations, opts.except(:suppress_master_key))
57
+ end
58
+ if groups.size == 1 &&
59
+ Parse::BatchOperation.credential_fingerprint(groups.first[:client]) ==
60
+ Parse::BatchOperation.credential_fingerprint(self)
61
+ send_opts = opts.except(:session_token, :use_master_key, :suppress_master_key)
62
+ .merge(groups.first[:opts])
63
+ return post_batch_operation(batch_operations, send_opts)
64
+ end
65
+ if batch_operations.transaction
66
+ raise Parse::BatchOperation::MixedAuthorityError,
67
+ "A transaction cannot mix requests built for different credentials or " \
68
+ "another client. Parse Server runs a batch under one credential, so it " \
69
+ "cannot honor each request's own. Save these objects in separate transactions."
70
+ end
71
+ # Mixed credentials, not a transaction: send one call per set of
72
+ # credentials, keeping responses in request order.
73
+ # Assigned, not re-added: `add` would drop tagged duplicates and leave
74
+ # fewer responses than requests.
75
+ routed = Parse::BatchOperation.new
76
+ routed.requests = batch_operations.requests.dup
77
+ routed.client = self
78
+ routed.batch_defaults = call_auth
79
+ routed.submit
80
+ end
81
+
82
+ private
83
+
84
+ # Send one `POST /batch` call and map its result to responses.
85
+ # @param batch_operations [Parse::BatchOperation]
86
+ # @param opts [Hash] request options for the call.
87
+ def post_batch_operation(batch_operations, opts)
88
+ opts = opts.dup
89
+ headers = nil
90
+ # Sent as the header itself, exactly as a single request carries it:
91
+ # the authentication middleware drops the master key when it is set,
92
+ # even with `use_master_key: true`.
93
+ if opts.delete(:suppress_master_key)
94
+ headers = { Parse::Middleware::Authentication::DISABLE_MASTER_KEY => "true" }
95
+ end
96
+ response = request(:post, "batch", body: batch_operations.as_json, headers: headers, opts: opts)
29
97
  return response.batch_responses if response.success? && response.batch?
30
98
  return response if response.error?
31
99
  # A successful HTTP response whose body is not an array of results
@@ -1168,6 +1168,7 @@ module Parse
1168
1168
  def run_atlas_pipeline!(collection_name, pipeline, max_time_ms = nil, read_preference: nil,
1169
1169
  authorizing_client: nil)
1170
1170
  agg_opts = {}
1171
+ max_time_ms ||= Parse::PipelineSecurity.regex_time_budget(pipeline)
1171
1172
  agg_opts[:max_time_ms] = max_time_ms if max_time_ms
1172
1173
  # Atlas Search does not go through Parse::MongoDB.aggregate, so this
1173
1174
  # is the only place its reads meet the binding guard.