parse-stack-next 5.8.3 → 5.8.4
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 +309 -0
- data/README.md +20 -6
- data/bin/parse-console +9 -3
- data/docs/TEST_SERVER.md +1 -1
- data/docs/caching.md +7 -2
- data/docs/mcp_guide.md +34 -0
- data/lib/parse/agent/constraint_translator.rb +66 -2
- data/lib/parse/agent/mcp_rack_app.rb +90 -1
- data/lib/parse/agent/mcp_server.rb +24 -2
- data/lib/parse/agent/mcp_subscriptions.rb +32 -1
- data/lib/parse/agent/tools.rb +3 -3
- data/lib/parse/agent.rb +14 -3
- data/lib/parse/atlas_search/protected_paths.rb +163 -0
- data/lib/parse/atlas_search.rb +62 -0
- data/lib/parse/client.rb +148 -6
- data/lib/parse/clp_scope.rb +53 -2
- data/lib/parse/embeddings/cache.rb +29 -9
- data/lib/parse/graphql/type_generator.rb +184 -5
- data/lib/parse/live_query/client.rb +27 -9
- data/lib/parse/live_query/event.rb +55 -1
- data/lib/parse/live_query/event_queue.rb +6 -1
- data/lib/parse/live_query/health_monitor.rb +1 -1
- data/lib/parse/model/file.rb +37 -3
- data/lib/parse/mongodb.rb +19 -0
- data/lib/parse/pipeline_security.rb +242 -5
- data/lib/parse/query/constraints.rb +101 -8
- data/lib/parse/query/cursor.rb +161 -13
- data/lib/parse/query.rb +42 -0
- data/lib/parse/retrieval/agent_tool.rb +82 -0
- data/lib/parse/retrieval/retriever.rb +14 -2
- data/lib/parse/stack/generators/templates/parse.rb +1 -1
- data/lib/parse/stack/rack_advisory.rb +53 -0
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +106 -2
- data/lib/parse/two_factor_auth/user_extension.rb +41 -5
- data/lib/parse/two_factor_auth.rb +81 -4
- data/lib/parse/vector_search/hybrid.rb +21 -0
- data/lib/parse/vector_search.rb +15 -0
- data/lib/parse/webhooks/replay_protection.rb +5 -1
- data/lib/parse/webhooks.rb +23 -4
- data/parse-stack-next.gemspec +12 -5
- metadata +14 -7
data/lib/parse/client.rb
CHANGED
|
@@ -414,7 +414,7 @@ module Parse
|
|
|
414
414
|
# a client is logged or surfaced in an error reporter. Show only the
|
|
415
415
|
# connection identity and a boolean for each credential's presence.
|
|
416
416
|
def inspect
|
|
417
|
-
"#<#{self.class.name} server_url=#{@server_url.inspect} " \
|
|
417
|
+
"#<#{self.class.name} server_url=#{self.class.redact_url_userinfo(@server_url).inspect} " \
|
|
418
418
|
"app_id=#{@application_id.inspect} master_key=#{@master_key ? "[FILTERED]" : "nil"} " \
|
|
419
419
|
"session_token=#{@session_token ? "[FILTERED]" : "nil"}>"
|
|
420
420
|
end
|
|
@@ -426,7 +426,7 @@ module Parse
|
|
|
426
426
|
# @return [Hash]
|
|
427
427
|
def as_json(*)
|
|
428
428
|
{
|
|
429
|
-
"server_url" => @server_url,
|
|
429
|
+
"server_url" => self.class.redact_url_userinfo(@server_url),
|
|
430
430
|
"app_id" => @application_id,
|
|
431
431
|
"master_key" => @master_key ? "[FILTERED]" : nil,
|
|
432
432
|
"session_token" => @session_token ? "[FILTERED]" : nil,
|
|
@@ -673,6 +673,16 @@ module Parse
|
|
|
673
673
|
# You may set the default expiration time with the expires option.
|
|
674
674
|
# At any point in time you may clear the cache by calling the {Parse::Client#clear_cache!}
|
|
675
675
|
# method on the client connection. See {https://github.com/minad/moneta Moneta}.
|
|
676
|
+
# A store that deserializes values with Marshal over a backend outside
|
|
677
|
+
# this process (Moneta's default, e.g. `Moneta.new(:Redis, url: ...)`)
|
|
678
|
+
# raises ArgumentError. Use {Parse::Cache::Redis} (or a `redis://` URL
|
|
679
|
+
# string), which stores JSON. In-process stores such as
|
|
680
|
+
# `Moneta.new(:Memory)` are accepted. A plain `value_serializer: nil`
|
|
681
|
+
# is not a substitute: the response cache stores Hash values, which a
|
|
682
|
+
# file or Redis backend cannot hold without a serializer.
|
|
683
|
+
# @option opts [Boolean] :allow_marshal_cache Accept a :cache store that
|
|
684
|
+
# deserializes values with Marshal over an external backend. Logs a
|
|
685
|
+
# security warning. Default false.
|
|
676
686
|
# @option opts [Integer] :expires Sets the default cache expiration time
|
|
677
687
|
# (in seconds) for successful non-empty GET requests when using the caching
|
|
678
688
|
# middleware. The default value is 3 seconds. If :expires is set to 0,
|
|
@@ -739,20 +749,30 @@ module Parse
|
|
|
739
749
|
bound_token = bound_token.to_s.strip
|
|
740
750
|
@session_token = bound_token.empty? ? nil : bound_token
|
|
741
751
|
|
|
742
|
-
@require_https = opts.fetch(:require_https
|
|
752
|
+
@require_https = opts.fetch(:require_https) { self.class.env_flag_enabled?(ENV["PARSE_REQUIRE_HTTPS"]) }
|
|
743
753
|
@allow_faraday_proxy = opts.fetch(:allow_faraday_proxy, false)
|
|
744
754
|
|
|
745
755
|
# Security check for HTTP usage (except localhost/127.0.0.1 for development)
|
|
746
756
|
# The scheme and host come from URI parsing, so `HTTP://` and leading
|
|
747
757
|
# whitespace are treated as plain http, not as a secure URL.
|
|
758
|
+
# Credentials embedded in the URL (`https://user:pass@host/parse`) are
|
|
759
|
+
# sent as a Basic Authorization header and would otherwise appear in
|
|
760
|
+
# every message below, in #inspect, and in #as_json. Those surfaces
|
|
761
|
+
# print a redacted form; warn once so the operator can move them out.
|
|
762
|
+
if self.class.url_userinfo?(@server_url) && !Parse::Client.instance_variable_get(:@userinfo_warned)
|
|
763
|
+
Parse::Client.instance_variable_set(:@userinfo_warned, true)
|
|
764
|
+
warn "[Parse::Client] SECURITY WARNING: the Parse server URL contains embedded credentials " \
|
|
765
|
+
"(user:password@). They are redacted from logs, but prefer configuring them outside the URL: " \
|
|
766
|
+
"#{self.class.redact_url_userinfo(@server_url)}"
|
|
767
|
+
end
|
|
748
768
|
if self.class.url_scheme(@server_url) == "http" && !self.class.loopback_host?(self.class.url_host(@server_url))
|
|
749
769
|
if @require_https
|
|
750
|
-
raise ArgumentError, "[Parse::Client] HTTPS required but server URL uses HTTP: #{@server_url}. " \
|
|
770
|
+
raise ArgumentError, "[Parse::Client] HTTPS required but server URL uses HTTP: #{self.class.redact_url_userinfo(@server_url)}. " \
|
|
751
771
|
"Set require_https: false or use an HTTPS URL."
|
|
752
772
|
else
|
|
753
773
|
warn "[Parse::Client] SECURITY WARNING: Using HTTP instead of HTTPS for Parse server. " \
|
|
754
774
|
"This exposes credentials and data to network interception. " \
|
|
755
|
-
"Use HTTPS in production: #{@server_url}"
|
|
775
|
+
"Use HTTPS in production: #{self.class.redact_url_userinfo(@server_url)}"
|
|
756
776
|
end
|
|
757
777
|
end
|
|
758
778
|
|
|
@@ -887,6 +907,29 @@ module Parse
|
|
|
887
907
|
raise ArgumentError, "Parse::Client option :cache needs to be a type of Moneta store"
|
|
888
908
|
end
|
|
889
909
|
|
|
910
|
+
# SECURITY: a caller-built Moneta store keeps Moneta's default
|
|
911
|
+
# Marshal value serializer unless told otherwise, so every cache
|
|
912
|
+
# hit would `Marshal.load` the bytes stored in the backend. For a
|
|
913
|
+
# shared or network-reachable backend (Redis over `redis://`, a
|
|
914
|
+
# cache another service can write) that is a code-execution
|
|
915
|
+
# primitive. Refuse it unless the caller opts in explicitly.
|
|
916
|
+
# In-process stores (`:Memory`, `:LRUHash`, `:Null`) are exempt:
|
|
917
|
+
# only this process can write the values they hold.
|
|
918
|
+
if self.class.marshal_value_cache?(opts[:cache])
|
|
919
|
+
unless opts[:allow_marshal_cache]
|
|
920
|
+
raise ArgumentError,
|
|
921
|
+
"[Parse::Client] The :cache store deserializes values with Marshal " \
|
|
922
|
+
"(Moneta's default value serializer), which allows code execution by anyone " \
|
|
923
|
+
"able to write to the cache backend. Use Parse::Cache::Redis (or a redis:// URL " \
|
|
924
|
+
"string), an in-process store such as Moneta.new(:Memory), or pass " \
|
|
925
|
+
"`allow_marshal_cache: true` " \
|
|
926
|
+
"to accept the risk."
|
|
927
|
+
end
|
|
928
|
+
warn "[Parse::Client] SECURITY WARNING: allow_marshal_cache: true accepts a cache store " \
|
|
929
|
+
"that deserializes values with Marshal. Anyone able to write to the cache backend " \
|
|
930
|
+
"can execute code in this process."
|
|
931
|
+
end
|
|
932
|
+
|
|
890
933
|
# If the caller passed a `Parse::Cache::Redis` wrapper, let its
|
|
891
934
|
# built-in namespace flow through automatically. An explicit
|
|
892
935
|
# `cache_namespace:` still wins so callers can override.
|
|
@@ -1148,6 +1191,95 @@ module Parse
|
|
|
1148
1191
|
nil
|
|
1149
1192
|
end
|
|
1150
1193
|
|
|
1194
|
+
# Matches an authority whose userinfo (`user:pass@`) precedes the host.
|
|
1195
|
+
# Greedy up to the last `@` before the path, so an unencoded `@` in the
|
|
1196
|
+
# password is still covered.
|
|
1197
|
+
URL_USERINFO_PATTERN = %r{\A([[:space:]]*[a-z][a-z0-9+.\-]*://)[^/?#]*@}i
|
|
1198
|
+
private_constant :URL_USERINFO_PATTERN
|
|
1199
|
+
|
|
1200
|
+
# Whether a URL String embeds userinfo (`user:pass@host`).
|
|
1201
|
+
# @param url [String, nil]
|
|
1202
|
+
# @return [Boolean]
|
|
1203
|
+
# @api private
|
|
1204
|
+
def self.url_userinfo?(url)
|
|
1205
|
+
!url.nil? && URL_USERINFO_PATTERN.match?(url.to_s)
|
|
1206
|
+
end
|
|
1207
|
+
|
|
1208
|
+
# The URL with any userinfo replaced by `[FILTERED]`, for logging and
|
|
1209
|
+
# serialization. Other parts of the URL are unchanged.
|
|
1210
|
+
# @param url [String, nil]
|
|
1211
|
+
# @return [String, nil]
|
|
1212
|
+
# @api private
|
|
1213
|
+
def self.redact_url_userinfo(url)
|
|
1214
|
+
return url if url.nil?
|
|
1215
|
+
url.to_s.sub(URL_USERINFO_PATTERN, '\1[FILTERED]@')
|
|
1216
|
+
end
|
|
1217
|
+
|
|
1218
|
+
# Whether an environment flag is set to a true value: `true`, `1`, or
|
|
1219
|
+
# `yes`, ignoring case and surrounding whitespace.
|
|
1220
|
+
# @param value [String, nil]
|
|
1221
|
+
# @return [Boolean]
|
|
1222
|
+
# @api private
|
|
1223
|
+
def self.env_flag_enabled?(value)
|
|
1224
|
+
%w[true 1 yes].include?(value.to_s.strip.downcase)
|
|
1225
|
+
end
|
|
1226
|
+
|
|
1227
|
+
# Whether a request path would leave the configured server: it has a URI
|
|
1228
|
+
# scheme (`https:`, `HTTP:`, any other `scheme:`) or is protocol-relative
|
|
1229
|
+
# (`//host`, or the `\\host`, `/\host` and `\/host` forms some URL
|
|
1230
|
+
# parsers normalize to it). Leading whitespace and control characters are
|
|
1231
|
+
# ignored. Relative paths, with or without a query string, are safe.
|
|
1232
|
+
# @param uri [String, URI, nil]
|
|
1233
|
+
# @return [Boolean]
|
|
1234
|
+
# @api private
|
|
1235
|
+
def self.unsafe_request_path?(uri)
|
|
1236
|
+
return false if uri.nil?
|
|
1237
|
+
path = uri.to_s.sub(/\A[[:space:][:cntrl:]]+/, "")
|
|
1238
|
+
path.match?(/\A[a-z][a-z0-9+.\-]*:/i) || path.match?(%r{\A[/\\]{2}})
|
|
1239
|
+
end
|
|
1240
|
+
|
|
1241
|
+
# In-process Moneta adapters. Values they hold were written by this
|
|
1242
|
+
# process, so a Marshal value serializer on them is not reachable from
|
|
1243
|
+
# outside it.
|
|
1244
|
+
IN_PROCESS_CACHE_ADAPTERS = %w[
|
|
1245
|
+
Moneta::Adapters::Memory
|
|
1246
|
+
Moneta::Adapters::LRUHash
|
|
1247
|
+
Moneta::Adapters::Null
|
|
1248
|
+
].freeze
|
|
1249
|
+
private_constant :IN_PROCESS_CACHE_ADAPTERS
|
|
1250
|
+
|
|
1251
|
+
# Whether a Moneta store applies a Marshal value transformer over a
|
|
1252
|
+
# backend outside this process. Walks the proxy chain through `adapter`
|
|
1253
|
+
# (Expires, Logger, Transformer, ...) to the innermost adapter. Moneta
|
|
1254
|
+
# names each compiled transformer class after its chains, for example
|
|
1255
|
+
# `MarshalPrefixKeyMarshalValue`, so the value chain is the part after the
|
|
1256
|
+
# last `Key`. {Parse::Cache::Redis} disables Moneta's value serializer and
|
|
1257
|
+
# is never flagged.
|
|
1258
|
+
# @param store [Object]
|
|
1259
|
+
# @return [Boolean]
|
|
1260
|
+
# @api private
|
|
1261
|
+
def self.marshal_value_cache?(store)
|
|
1262
|
+
return false if defined?(Parse::Cache::Redis) && store.is_a?(Parse::Cache::Redis)
|
|
1263
|
+
marshal_value = false
|
|
1264
|
+
innermost = store
|
|
1265
|
+
node = store
|
|
1266
|
+
16.times do
|
|
1267
|
+
break if node.nil?
|
|
1268
|
+
innermost = node
|
|
1269
|
+
name = node.class.name.to_s.split("::").last.to_s
|
|
1270
|
+
if node.class.name.to_s.start_with?("Moneta::Transformer::") && name.end_with?("Value")
|
|
1271
|
+
marshal_value ||= name.sub(/\A.*Key/, "").include?("Marshal")
|
|
1272
|
+
end
|
|
1273
|
+
break unless node.respond_to?(:adapter)
|
|
1274
|
+
nxt = node.adapter
|
|
1275
|
+
break if nxt.equal?(node)
|
|
1276
|
+
node = nxt
|
|
1277
|
+
end
|
|
1278
|
+
marshal_value && !IN_PROCESS_CACHE_ADAPTERS.include?(innermost.class.name.to_s)
|
|
1279
|
+
rescue StandardError
|
|
1280
|
+
false
|
|
1281
|
+
end
|
|
1282
|
+
|
|
1151
1283
|
# Configure LiveQuery with the given options
|
|
1152
1284
|
# @param opts [Hash] configuration options
|
|
1153
1285
|
# @option opts [String] :live_query_url WebSocket URL for LiveQuery server (wss://...)
|
|
@@ -1321,7 +1453,9 @@ module Parse
|
|
|
1321
1453
|
# is raised.
|
|
1322
1454
|
# @param method [Symbol] The method type of the HTTP request (ex. :get, :post).
|
|
1323
1455
|
# - This parameter can also be a {Parse::Request} object.
|
|
1324
|
-
# @param uri [String] the url path
|
|
1456
|
+
# @param uri [String] the url path, relative to the configured server URL.
|
|
1457
|
+
# An absolute (`https://...`) or protocol-relative (`//host/...`) path
|
|
1458
|
+
# raises ArgumentError.
|
|
1325
1459
|
# @param body [Hash] the body of the request.
|
|
1326
1460
|
# @param query [Hash] the set of url query parameters to use in a GET request.
|
|
1327
1461
|
# @param headers [Hash] additional headers to apply to this request.
|
|
@@ -1452,6 +1586,14 @@ module Parse
|
|
|
1452
1586
|
|
|
1453
1587
|
# http method
|
|
1454
1588
|
method = method.downcase.to_sym
|
|
1589
|
+
# Faraday resolves an absolute or protocol-relative path against the
|
|
1590
|
+
# configured base URL by replacing its host, so a caller-supplied
|
|
1591
|
+
# path such as `https://other.example/x` or `//other.example/x` would
|
|
1592
|
+
# carry the master key and session token to that host.
|
|
1593
|
+
if self.class.unsafe_request_path?(uri)
|
|
1594
|
+
raise ArgumentError, "[Parse::Client] Refusing request path #{uri.to_s.strip[0, 80].inspect}: " \
|
|
1595
|
+
"paths must be relative to the configured server URL."
|
|
1596
|
+
end
|
|
1455
1597
|
# set the User-Agent
|
|
1456
1598
|
headers[USER_AGENT_HEADER] = USER_AGENT_VERSION
|
|
1457
1599
|
|
data/lib/parse/clp_scope.rb
CHANGED
|
@@ -92,6 +92,10 @@ module Parse
|
|
|
92
92
|
# stops a down server from being refetched on every read in the block.
|
|
93
93
|
SUPPRESSED_NEGATIVE_TTL = 2
|
|
94
94
|
|
|
95
|
+
# Fiber-local key for the per-request entry snapshot ({.with_snapshot}).
|
|
96
|
+
SNAPSHOT_KEY = :__parse_clp_scope_snapshot
|
|
97
|
+
private_constant :SNAPSHOT_KEY
|
|
98
|
+
|
|
95
99
|
@cache = {}
|
|
96
100
|
@suppressed_failures = {}
|
|
97
101
|
@cache_mutex = Mutex.new
|
|
@@ -291,7 +295,12 @@ module Parse
|
|
|
291
295
|
access_evaluation(:denied, role_claims: role_claims, reason: reason)
|
|
292
296
|
end
|
|
293
297
|
|
|
294
|
-
|
|
298
|
+
# @param strict [Boolean] when true, an unresolvable schema raises
|
|
299
|
+
# {Denied} instead of returning an empty set. Callers that use the
|
|
300
|
+
# set to REFUSE a request (the pipeline reference scanner) pass
|
|
301
|
+
# true: an empty set there would let the request through.
|
|
302
|
+
# @raise [Denied] when `strict` and the schema is unresolvable.
|
|
303
|
+
def protected_fields_for(class_name, permission_strings, client: nil, strict: false)
|
|
295
304
|
return EMPTY_SET if permission_strings.nil?
|
|
296
305
|
|
|
297
306
|
entry = fetch(class_name, client: client)
|
|
@@ -299,7 +308,15 @@ module Parse
|
|
|
299
308
|
# `permits?` already refused the query, so this branch is only
|
|
300
309
|
# reached when callers ask for the protected-fields set directly
|
|
301
310
|
# (e.g. for documentation or audit tooling).
|
|
302
|
-
|
|
311
|
+
if entry.kind == :unresolvable
|
|
312
|
+
if strict
|
|
313
|
+
warn_unresolvable_once!(class_name)
|
|
314
|
+
raise Denied.new(class_name, :find,
|
|
315
|
+
"protectedFields for '#{class_name}' are unresolvable; " \
|
|
316
|
+
"refusing the pipeline for the current scope.")
|
|
317
|
+
end
|
|
318
|
+
return EMPTY_SET
|
|
319
|
+
end
|
|
303
320
|
stored_map = if entry.kind == :no_clp
|
|
304
321
|
nil
|
|
305
322
|
else
|
|
@@ -539,6 +556,30 @@ module Parse
|
|
|
539
556
|
@warned_unresolvable_classes = Set.new
|
|
540
557
|
end
|
|
541
558
|
|
|
559
|
+
# Pin every schema entry read inside the block to the first value
|
|
560
|
+
# read for its cache key, for the current thread (fiber).
|
|
561
|
+
#
|
|
562
|
+
# A scoped mongo-direct request reads the schema several times: the
|
|
563
|
+
# protected-field reference scanner, the CLP row gate, the join gate,
|
|
564
|
+
# and the protected-field strip. Each read used to consult the shared
|
|
565
|
+
# cache on its own, so a TTL expiry and refetch between two reads
|
|
566
|
+
# could give the scanner one schema and the gate another (an
|
|
567
|
+
# unresolvable scan with an empty protected set, then a successful
|
|
568
|
+
# gate). Inside this block every read for a class sees one entry, so
|
|
569
|
+
# the decisions agree. Nested calls reuse the outer snapshot.
|
|
570
|
+
#
|
|
571
|
+
# @yield the request whose schema reads should agree.
|
|
572
|
+
# @return [Object] the block's value.
|
|
573
|
+
def with_snapshot
|
|
574
|
+
return yield if Thread.current[SNAPSHOT_KEY]
|
|
575
|
+
Thread.current[SNAPSHOT_KEY] = {}
|
|
576
|
+
begin
|
|
577
|
+
yield
|
|
578
|
+
ensure
|
|
579
|
+
Thread.current[SNAPSHOT_KEY] = nil
|
|
580
|
+
end
|
|
581
|
+
end
|
|
582
|
+
|
|
542
583
|
private
|
|
543
584
|
|
|
544
585
|
# Collect one field set per protectedFields group that applies to the
|
|
@@ -606,6 +647,16 @@ module Parse
|
|
|
606
647
|
resolved_client = client || schema_client || default_client_safe
|
|
607
648
|
key = cache_key(class_key, resolved_client)
|
|
608
649
|
|
|
650
|
+
snapshot = Thread.current[SNAPSHOT_KEY]
|
|
651
|
+
return snapshot[key] if snapshot&.key?(key)
|
|
652
|
+
entry = fetch_entry(class_key, resolved_client, key)
|
|
653
|
+
snapshot[key] = entry if snapshot
|
|
654
|
+
entry
|
|
655
|
+
end
|
|
656
|
+
|
|
657
|
+
# Resolve the cached or freshly fetched entry for one cache key.
|
|
658
|
+
# @!visibility private
|
|
659
|
+
def fetch_entry(class_key, resolved_client, key)
|
|
609
660
|
cached = @cache_mutex.synchronize { @cache[key] }
|
|
610
661
|
return cached if cached && !stale?(cached)
|
|
611
662
|
|
|
@@ -110,8 +110,9 @@ module Parse
|
|
|
110
110
|
# MITM, and the cache key is derived from (often user-supplied)
|
|
111
111
|
# embedded text. `MonetaStore` JSON-(de)serializes values itself, but
|
|
112
112
|
# that only closes the vector IF Moneta is not also Marshaling on top;
|
|
113
|
-
# `value_serializer: nil` ensures it is not. `MonetaStore`
|
|
114
|
-
#
|
|
113
|
+
# `value_serializer: nil` ensures it is not. `MonetaStore` raises
|
|
114
|
+
# ArgumentError if it is handed a Marshal-serializing store over an
|
|
115
|
+
# external backend, unless constructed with `allow_marshal: true`.
|
|
115
116
|
# TTL is forwarded via Moneta's `expires:` option when the
|
|
116
117
|
# backend supports it, ignored otherwise.
|
|
117
118
|
#
|
|
@@ -130,18 +131,29 @@ module Parse
|
|
|
130
131
|
# forwarded as `expires:` when the backend supports
|
|
131
132
|
# `store(key, value, expires:)`. nil = no expiry.
|
|
132
133
|
# @param namespace [String] key prefix.
|
|
133
|
-
|
|
134
|
+
# @param allow_marshal [Boolean] accept a store that deserializes
|
|
135
|
+
# values with Marshal over a backend outside this process. Logs a
|
|
136
|
+
# security warning. Default false.
|
|
137
|
+
# @raise [ArgumentError] when the store deserializes values with
|
|
138
|
+
# Marshal over an external backend and `allow_marshal` is not set.
|
|
139
|
+
def initialize(moneta, ttl: nil, namespace: "emb:", allow_marshal: false)
|
|
134
140
|
unless moneta.respond_to?(:[]) && moneta.respond_to?(:[]=)
|
|
135
141
|
raise ArgumentError,
|
|
136
142
|
"Parse::Embeddings::Cache::MonetaStore expects a Moneta-compatible " \
|
|
137
143
|
"store responding to #[] and #[]= (got #{moneta.class})."
|
|
138
144
|
end
|
|
139
145
|
if marshaling_value_store?(moneta)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
146
|
+
unless allow_marshal
|
|
147
|
+
raise ArgumentError,
|
|
148
|
+
"Parse::Embeddings::Cache::MonetaStore: the supplied Moneta store " \
|
|
149
|
+
"deserializes values with Marshal, which allows code execution by anyone " \
|
|
150
|
+
"able to write to the backing store. Rebuild it with value_serializer: nil, " \
|
|
151
|
+
"e.g. Moneta.new(:Redis, url: ..., value_serializer: nil), or pass " \
|
|
152
|
+
"allow_marshal: true to accept the risk."
|
|
153
|
+
end
|
|
154
|
+
warn "[Parse::Embeddings::Cache::MonetaStore] SECURITY: allow_marshal: true accepts " \
|
|
155
|
+
"a Moneta store that deserializes values with Marshal. Anyone able to write to " \
|
|
156
|
+
"the backing store can execute code in this process."
|
|
145
157
|
end
|
|
146
158
|
@moneta = moneta
|
|
147
159
|
@ttl = ttl && Float(ttl)
|
|
@@ -204,8 +216,16 @@ module Parse
|
|
|
204
216
|
# Marshal. Moneta names its transformer proxy after the active
|
|
205
217
|
# serializers (e.g. "...MarshalValue"); a store built with
|
|
206
218
|
# value_serializer: nil has no "...Value" segment. Used only to warn.
|
|
219
|
+
# Shares the detection {Parse::Client} applies to its :cache option:
|
|
220
|
+
# a Marshal value transformer anywhere in the proxy chain over a
|
|
221
|
+
# backend outside this process. In-process stores (`:Memory`,
|
|
222
|
+
# `:LRUHash`, `:Null`) only hold values this process wrote.
|
|
207
223
|
def marshaling_value_store?(moneta)
|
|
208
|
-
|
|
224
|
+
if defined?(Parse::Client) && Parse::Client.respond_to?(:marshal_value_cache?)
|
|
225
|
+
Parse::Client.marshal_value_cache?(moneta)
|
|
226
|
+
else
|
|
227
|
+
moneta.class.name.to_s.include?("MarshalValue")
|
|
228
|
+
end
|
|
209
229
|
rescue StandardError
|
|
210
230
|
false
|
|
211
231
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# encoding: UTF-8
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
|
+
require_relative "../pipeline_security"
|
|
5
|
+
|
|
4
6
|
module Parse
|
|
5
7
|
module GraphQL
|
|
6
8
|
# Generates `GraphQL::Schema::Object` subclasses from Parse::Object
|
|
@@ -15,6 +17,47 @@ module Parse
|
|
|
15
17
|
# Pagination arguments, Loaders, and Relay connections are deferred
|
|
16
18
|
# until query/mutation passthrough lands (TODO §7).
|
|
17
19
|
#
|
|
20
|
+
# == Schema shape, not an authorization layer
|
|
21
|
+
#
|
|
22
|
+
# The generated types describe the shape of a Parse class. They do not
|
|
23
|
+
# authorize anything. Default graphql-ruby resolution reads whatever is
|
|
24
|
+
# on the object handed to the resolver, so the resolvers (or the objects
|
|
25
|
+
# passed to them) must enforce ACL, CLP, and protectedFields for the
|
|
26
|
+
# requesting user. The usual way is to resolve through queries run with
|
|
27
|
+
# that user's session token, so Parse Server applies its own checks.
|
|
28
|
+
# Omitting a field from a type does not stop a resolver from loading or
|
|
29
|
+
# returning it through another path.
|
|
30
|
+
#
|
|
31
|
+
# == Field policy
|
|
32
|
+
#
|
|
33
|
+
# Output is deterministic: the same model and the same options always
|
|
34
|
+
# produce the same fields. Without `protected_fields: :fetch`, no CLP
|
|
35
|
+
# cache or ambient state is consulted.
|
|
36
|
+
#
|
|
37
|
+
# * Credential denylist (always on). Session tokens, auth data,
|
|
38
|
+
# passwords, device tokens, and every column in
|
|
39
|
+
# {Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST} (see
|
|
40
|
+
# {SENSITIVE_FIELD_NAMES}) are dropped, matched by local accessor or
|
|
41
|
+
# wire name. The denylist only keeps known credential columns out; it
|
|
42
|
+
# is not a substitute for protectedFields.
|
|
43
|
+
# * `exclude_fields:` drops caller-listed fields.
|
|
44
|
+
# * `protected_fields:` applies the class's public (`"*"`)
|
|
45
|
+
# protectedFields, only when supplied or explicitly fetched. With the
|
|
46
|
+
# default (`nil`) no protectedFields lookup happens at all, so a field
|
|
47
|
+
# such as `_User.email` is emitted unless it is excluded or supplied
|
|
48
|
+
# here. Pass a Hash (`{ "ClassName" => [:field, ...] }`) to supply the
|
|
49
|
+
# lists, or `:fetch` to load each class's CLP from the server through
|
|
50
|
+
# `Parse::CLPScope.protected_fields_for` (a schema request per class,
|
|
51
|
+
# which includes `Parse::CLPScope.default_protected_fields`). A failed
|
|
52
|
+
# fetch raises {ProtectedFieldsUnavailable}.
|
|
53
|
+
# * `include_sensitive:` emits a field the denylist or protectedFields
|
|
54
|
+
# would drop. `exclude_fields:` still wins over it.
|
|
55
|
+
#
|
|
56
|
+
# has_many fields are emitted as plain unpaginated lists (`[Type]`), and
|
|
57
|
+
# nested selections are not bounded here. A schema built from these
|
|
58
|
+
# types should set `max_depth` and `max_complexity` (graphql-ruby schema
|
|
59
|
+
# class methods) so a single query cannot walk an unbounded object graph.
|
|
60
|
+
#
|
|
18
61
|
# Cross-class references (pointers, has_many) require all referenced
|
|
19
62
|
# model types to be in the registry BEFORE field emission. Use
|
|
20
63
|
# `generate_all` to codegen a list of models in one call (two-pass:
|
|
@@ -57,14 +100,53 @@ module Parse
|
|
|
57
100
|
# `id: ID` field separately.
|
|
58
101
|
OMITTED_TYPES = %i[acl].freeze
|
|
59
102
|
|
|
103
|
+
# Credential field names that are never emitted, whatever their
|
|
104
|
+
# declared type. Compared against both the local accessor and the
|
|
105
|
+
# Parse wire name. Columns starting with `_auth_data_` are also
|
|
106
|
+
# dropped.
|
|
107
|
+
SENSITIVE_FIELD_NAMES = (
|
|
108
|
+
%w[session_token sessionToken auth_data authData password
|
|
109
|
+
device_token deviceToken] +
|
|
110
|
+
Parse::PipelineSecurity::INTERNAL_FIELDS_DENYLIST
|
|
111
|
+
).to_set.freeze
|
|
112
|
+
|
|
113
|
+
# Prefixes of credential columns that are never emitted.
|
|
114
|
+
SENSITIVE_FIELD_PREFIXES = Parse::PipelineSecurity::INTERNAL_FIELDS_PREFIX_DENYLIST
|
|
115
|
+
|
|
116
|
+
# Raised when `protected_fields: :fetch` was requested and a class's
|
|
117
|
+
# CLP could not be loaded. The generator refuses to emit the type
|
|
118
|
+
# rather than emit fields the caller asked to have filtered.
|
|
119
|
+
class ProtectedFieldsUnavailable < StandardError; end
|
|
120
|
+
|
|
60
121
|
# Generate types for a list of models in one call. Two-pass:
|
|
61
122
|
# creates empty stub classes for every model first so cross-class
|
|
62
123
|
# references resolve regardless of declaration order.
|
|
63
124
|
#
|
|
125
|
+
# The result is a schema shape, not an authorization layer: resolvers
|
|
126
|
+
# must enforce ACL, CLP, and protectedFields for the requesting user
|
|
127
|
+
# (for example by resolving through queries run with that user's
|
|
128
|
+
# session token). has_many fields are unpaginated lists, so set
|
|
129
|
+
# `max_depth` and `max_complexity` on the schema.
|
|
130
|
+
#
|
|
64
131
|
# @param model_classes [Array<Class>] Parse::Object subclasses.
|
|
132
|
+
# @param include_sensitive [Array<Symbol, String>] fields to emit even
|
|
133
|
+
# though they are in {SENSITIVE_FIELD_NAMES} or the class's public
|
|
134
|
+
# protectedFields. Matched by local accessor or wire name.
|
|
135
|
+
# @param exclude_fields [Array<Symbol, String>, Hash] fields never to
|
|
136
|
+
# emit. An Array applies to every class; a Hash maps a Parse class
|
|
137
|
+
# name or model class to that class's Array. Matched by local
|
|
138
|
+
# accessor or wire name, and applied even over `include_sensitive:`.
|
|
139
|
+
# @param protected_fields [nil, :fetch, Hash] source of each class's
|
|
140
|
+
# public protectedFields. `nil` (default) performs no lookup.
|
|
141
|
+
# `:fetch` loads each class's CLP from the server and raises
|
|
142
|
+
# {ProtectedFieldsUnavailable} when it cannot. A Hash maps a Parse
|
|
143
|
+
# class name or model class to its protected field list.
|
|
65
144
|
# @return [Hash{String => Class}] registry of generated types
|
|
66
145
|
# keyed by Parse class name.
|
|
67
|
-
|
|
146
|
+
# @raise [ProtectedFieldsUnavailable] when `protected_fields: :fetch`
|
|
147
|
+
# cannot load a class's CLP.
|
|
148
|
+
def self.generate_all(model_classes, include_sensitive: [], exclude_fields: [], protected_fields: nil)
|
|
149
|
+
validate_protected_fields_option!(protected_fields)
|
|
68
150
|
registry = {}
|
|
69
151
|
# Pass 1: stub classes registered by parse_class name.
|
|
70
152
|
model_classes.each do |model|
|
|
@@ -73,7 +155,10 @@ module Parse
|
|
|
73
155
|
end
|
|
74
156
|
# Pass 2: populate fields. Cross-references now resolve.
|
|
75
157
|
model_classes.each do |model|
|
|
76
|
-
new(model, registry: registry, _prebuilt: registry[model.parse_class]
|
|
158
|
+
new(model, registry: registry, _prebuilt: registry[model.parse_class],
|
|
159
|
+
include_sensitive: include_sensitive,
|
|
160
|
+
exclude_fields: exclude_fields,
|
|
161
|
+
protected_fields: protected_fields).populate_fields
|
|
77
162
|
end
|
|
78
163
|
detect_name_collisions!(registry)
|
|
79
164
|
registry
|
|
@@ -101,17 +186,45 @@ module Parse
|
|
|
101
186
|
# in the registry — otherwise an error is raised at field-emit
|
|
102
187
|
# time. Prefer `generate_all` when you have a graph of models.
|
|
103
188
|
#
|
|
189
|
+
# The result is a schema shape, not an authorization layer; see
|
|
190
|
+
# {generate_all}.
|
|
191
|
+
#
|
|
104
192
|
# @param model_class [Class] a Parse::Object subclass.
|
|
105
193
|
# @param registry [Hash, nil] shared registry; mutated in place.
|
|
194
|
+
# @param include_sensitive [Array<Symbol, String>] see {generate_all}.
|
|
195
|
+
# @param exclude_fields [Array<Symbol, String>, Hash] see {generate_all}.
|
|
196
|
+
# @param protected_fields [nil, :fetch, Hash] see {generate_all}.
|
|
106
197
|
# @return [Class] anonymous `GraphQL::Schema::Object` subclass.
|
|
107
|
-
|
|
198
|
+
# @raise [ProtectedFieldsUnavailable] see {generate_all}.
|
|
199
|
+
def self.generate(model_class, registry: nil, include_sensitive: [], exclude_fields: [], protected_fields: nil)
|
|
108
200
|
validate!(model_class)
|
|
201
|
+
validate_protected_fields_option!(protected_fields)
|
|
109
202
|
registry ||= {}
|
|
110
203
|
stub = registry[model_class.parse_class] ||= build_stub(model_class)
|
|
111
|
-
new(model_class, registry: registry, _prebuilt: stub
|
|
204
|
+
new(model_class, registry: registry, _prebuilt: stub,
|
|
205
|
+
include_sensitive: include_sensitive,
|
|
206
|
+
exclude_fields: exclude_fields,
|
|
207
|
+
protected_fields: protected_fields).populate_fields
|
|
112
208
|
stub
|
|
113
209
|
end
|
|
114
210
|
|
|
211
|
+
def self.validate_protected_fields_option!(value)
|
|
212
|
+
return if value.nil? || value == :fetch || value.is_a?(Hash)
|
|
213
|
+
raise ArgumentError,
|
|
214
|
+
"Parse::GraphQL::TypeGenerator: protected_fields: must be nil, :fetch, " \
|
|
215
|
+
"or a Hash of class name => field list, got #{value.inspect}"
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# Look up a per-class option keyed by Parse class name (String or
|
|
219
|
+
# Symbol) or by the model class itself.
|
|
220
|
+
def self.per_class_value(map, model_class)
|
|
221
|
+
name = model_class.parse_class
|
|
222
|
+
[name, name.to_sym, model_class].each do |key|
|
|
223
|
+
return map[key] if map.key?(key)
|
|
224
|
+
end
|
|
225
|
+
nil
|
|
226
|
+
end
|
|
227
|
+
|
|
115
228
|
def self.validate!(model_class)
|
|
116
229
|
unless model_class.is_a?(Class) && model_class < Parse::Object
|
|
117
230
|
raise ArgumentError,
|
|
@@ -128,10 +241,15 @@ module Parse
|
|
|
128
241
|
end
|
|
129
242
|
end
|
|
130
243
|
|
|
131
|
-
def initialize(model_class, registry:, _prebuilt:
|
|
244
|
+
def initialize(model_class, registry:, _prebuilt:, include_sensitive: [],
|
|
245
|
+
exclude_fields: [], protected_fields: nil)
|
|
132
246
|
@model_class = model_class
|
|
133
247
|
@registry = registry
|
|
134
248
|
@klass = _prebuilt
|
|
249
|
+
@include_sensitive = Array(include_sensitive).map(&:to_s).to_set
|
|
250
|
+
excluded = exclude_fields.is_a?(Hash) ? self.class.per_class_value(exclude_fields, model_class) : exclude_fields
|
|
251
|
+
@exclude_fields = Array(excluded).map(&:to_s).to_set
|
|
252
|
+
@protected_fields_option = protected_fields
|
|
135
253
|
end
|
|
136
254
|
|
|
137
255
|
def populate_fields
|
|
@@ -153,6 +271,7 @@ module Parse
|
|
|
153
271
|
|
|
154
272
|
@model_class.field_map.each do |local_key, _remote_field|
|
|
155
273
|
next if skip_keys.include?(local_key)
|
|
274
|
+
next if withheld?(local_key)
|
|
156
275
|
parse_type = @model_class.fields[local_key]
|
|
157
276
|
next if parse_type.nil?
|
|
158
277
|
next if OMITTED_TYPES.include?(parse_type)
|
|
@@ -176,6 +295,7 @@ module Parse
|
|
|
176
295
|
def emit_belongs_to_fields
|
|
177
296
|
@model_class.references.each do |parse_field, target_class_name|
|
|
178
297
|
accessor = field_to_accessor(parse_field)
|
|
298
|
+
next if withheld?(accessor)
|
|
179
299
|
target = registry_lookup!(target_class_name)
|
|
180
300
|
@klass.field(accessor, target, null: true)
|
|
181
301
|
end
|
|
@@ -184,6 +304,7 @@ module Parse
|
|
|
184
304
|
def emit_has_one_fields
|
|
185
305
|
return unless @model_class.respond_to?(:has_one_associations)
|
|
186
306
|
@model_class.has_one_associations.each do |name, meta|
|
|
307
|
+
next if withheld?(name)
|
|
187
308
|
target = registry_lookup!(meta[:target_class])
|
|
188
309
|
@klass.field(name, target, null: true)
|
|
189
310
|
end
|
|
@@ -192,6 +313,7 @@ module Parse
|
|
|
192
313
|
def emit_has_many_fields
|
|
193
314
|
return unless @model_class.respond_to?(:has_many_associations)
|
|
194
315
|
@model_class.has_many_associations.each do |name, meta|
|
|
316
|
+
next if withheld?(name)
|
|
195
317
|
target = registry_lookup!(meta[:target_class])
|
|
196
318
|
@klass.field(name, [target], null: true)
|
|
197
319
|
end
|
|
@@ -218,6 +340,63 @@ module Parse
|
|
|
218
340
|
end
|
|
219
341
|
end
|
|
220
342
|
|
|
343
|
+
# True when a field must not be emitted. Order: an explicit
|
|
344
|
+
# `exclude_fields:` entry always drops the field; `include_sensitive:`
|
|
345
|
+
# then keeps it; otherwise a credential name or a public protectedField
|
|
346
|
+
# from the `protected_fields:` source drops it.
|
|
347
|
+
def withheld?(local_key)
|
|
348
|
+
local = local_key.to_s
|
|
349
|
+
remote = (@model_class.field_map[local_key.to_sym] || local).to_s
|
|
350
|
+
names = [local, remote]
|
|
351
|
+
return true if names.any? { |n| @exclude_fields.include?(n) }
|
|
352
|
+
return false if names.any? { |n| @include_sensitive.include?(n) }
|
|
353
|
+
return true if names.any? { |n| sensitive_name?(n) }
|
|
354
|
+
names.any? { |n| public_protected_fields.include?(n) }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
def sensitive_name?(name)
|
|
358
|
+
SENSITIVE_FIELD_NAMES.include?(name) ||
|
|
359
|
+
SENSITIVE_FIELD_PREFIXES.any? { |prefix| name.start_with?(prefix) }
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
# Public protectedFields for this class from the explicit
|
|
363
|
+
# `protected_fields:` source. `nil` performs no lookup, a Hash supplies
|
|
364
|
+
# the list, and `:fetch` loads the CLP from the server.
|
|
365
|
+
def public_protected_fields
|
|
366
|
+
return @public_protected_fields if defined?(@public_protected_fields)
|
|
367
|
+
list = case @protected_fields_option
|
|
368
|
+
when nil then []
|
|
369
|
+
when :fetch then fetch_public_protected_fields
|
|
370
|
+
when Hash then self.class.per_class_value(@protected_fields_option, @model_class)
|
|
371
|
+
end
|
|
372
|
+
@public_protected_fields = Array(list).map(&:to_s).to_set
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Loads the class's CLP through Parse::CLPScope, which the caller opted
|
|
376
|
+
# into with `protected_fields: :fetch`. Parse::CLPScope records a failed
|
|
377
|
+
# schema request as unresolvable and reports an empty protected set for
|
|
378
|
+
# it, so resolvability is checked first and a failure raises instead of
|
|
379
|
+
# emitting every field.
|
|
380
|
+
def fetch_public_protected_fields
|
|
381
|
+
class_name = @model_class.parse_class
|
|
382
|
+
evaluation = Parse::CLPScope.evaluate_access(class_name, :get, claims: ["*"], authenticated: false)
|
|
383
|
+
if evaluation.reason == :clp_unresolvable
|
|
384
|
+
raise ProtectedFieldsUnavailable,
|
|
385
|
+
"Parse::GraphQL::TypeGenerator: could not load the CLP for #{class_name} " \
|
|
386
|
+
"(protected_fields: :fetch). Check the schema request, or supply " \
|
|
387
|
+
"protected_fields: as a Hash."
|
|
388
|
+
end
|
|
389
|
+
# strict: an entry that expires and fails to refetch after the check
|
|
390
|
+
# above raises instead of reading as "nothing protected".
|
|
391
|
+
Parse::CLPScope.protected_fields_for(class_name, ["*"], strict: true)
|
|
392
|
+
rescue ProtectedFieldsUnavailable
|
|
393
|
+
raise
|
|
394
|
+
rescue StandardError => e
|
|
395
|
+
raise ProtectedFieldsUnavailable,
|
|
396
|
+
"Parse::GraphQL::TypeGenerator: could not load protectedFields for " \
|
|
397
|
+
"#{class_name} (protected_fields: :fetch): #{e.class}: #{e.message}"
|
|
398
|
+
end
|
|
399
|
+
|
|
221
400
|
def registry_lookup!(target_class_name)
|
|
222
401
|
target = @registry[target_class_name]
|
|
223
402
|
if target.nil?
|