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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +312 -0
- data/README.md +6 -0
- data/docs/caching.md +40 -5
- data/docs/mcp_guide.md +7 -2
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +35 -2
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +316 -50
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search.rb +1 -0
- data/lib/parse/client/batch.rb +231 -12
- data/lib/parse/client/body_builder.rb +100 -15
- data/lib/parse/client/caching.rb +53 -3
- data/lib/parse/client/request.rb +59 -0
- data/lib/parse/client/response.rb +63 -5
- data/lib/parse/client.rb +147 -31
- data/lib/parse/live_query/client.rb +70 -9
- data/lib/parse/model/classes/session.rb +44 -16
- data/lib/parse/model/core/actions.rb +22 -1
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/mongodb.rb +24 -1
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraints.rb +634 -14
- data/lib/parse/query.rb +9 -5
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +6 -2
- data/lib/parse/vector_search/hybrid.rb +4 -1
- data/lib/parse/vector_search.rb +1 -0
- data/lib/parse/webhooks/payload.rb +39 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +17 -4
- metadata +1 -1
data/lib/parse/agent/errors.rb
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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
|
|
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.
|
|
910
|
-
#
|
|
911
|
-
#
|
|
912
|
-
#
|
|
913
|
-
#
|
|
914
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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(
|
|
1312
|
+
Parse::ACLScope.resolve_for_role(role_scope, client: @client)
|
|
1270
1313
|
end
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
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
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
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
|
|
1311
|
-
# underlying _Session row (the
|
|
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
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
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
|
|
1942
|
-
#
|
|
1943
|
-
#
|
|
1944
|
-
#
|
|
1945
|
-
#
|
|
1946
|
-
#
|
|
1947
|
-
#
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
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
|
-
|
|
1979
|
-
|
|
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,
|
data/lib/parse/api/batch.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/parse/atlas_search.rb
CHANGED
|
@@ -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.
|