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.
Files changed (43) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +309 -0
  3. data/README.md +20 -6
  4. data/bin/parse-console +9 -3
  5. data/docs/TEST_SERVER.md +1 -1
  6. data/docs/caching.md +7 -2
  7. data/docs/mcp_guide.md +34 -0
  8. data/lib/parse/agent/constraint_translator.rb +66 -2
  9. data/lib/parse/agent/mcp_rack_app.rb +90 -1
  10. data/lib/parse/agent/mcp_server.rb +24 -2
  11. data/lib/parse/agent/mcp_subscriptions.rb +32 -1
  12. data/lib/parse/agent/tools.rb +3 -3
  13. data/lib/parse/agent.rb +14 -3
  14. data/lib/parse/atlas_search/protected_paths.rb +163 -0
  15. data/lib/parse/atlas_search.rb +62 -0
  16. data/lib/parse/client.rb +148 -6
  17. data/lib/parse/clp_scope.rb +53 -2
  18. data/lib/parse/embeddings/cache.rb +29 -9
  19. data/lib/parse/graphql/type_generator.rb +184 -5
  20. data/lib/parse/live_query/client.rb +27 -9
  21. data/lib/parse/live_query/event.rb +55 -1
  22. data/lib/parse/live_query/event_queue.rb +6 -1
  23. data/lib/parse/live_query/health_monitor.rb +1 -1
  24. data/lib/parse/model/file.rb +37 -3
  25. data/lib/parse/mongodb.rb +19 -0
  26. data/lib/parse/pipeline_security.rb +242 -5
  27. data/lib/parse/query/constraints.rb +101 -8
  28. data/lib/parse/query/cursor.rb +161 -13
  29. data/lib/parse/query.rb +42 -0
  30. data/lib/parse/retrieval/agent_tool.rb +82 -0
  31. data/lib/parse/retrieval/retriever.rb +14 -2
  32. data/lib/parse/stack/generators/templates/parse.rb +1 -1
  33. data/lib/parse/stack/rack_advisory.rb +53 -0
  34. data/lib/parse/stack/version.rb +1 -1
  35. data/lib/parse/stack.rb +106 -2
  36. data/lib/parse/two_factor_auth/user_extension.rb +41 -5
  37. data/lib/parse/two_factor_auth.rb +81 -4
  38. data/lib/parse/vector_search/hybrid.rb +21 -0
  39. data/lib/parse/vector_search.rb +15 -0
  40. data/lib/parse/webhooks/replay_protection.rb +5 -1
  41. data/lib/parse/webhooks.rb +23 -4
  42. data/parse-stack-next.gemspec +12 -5
  43. 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, ENV["PARSE_REQUIRE_HTTPS"] == "true")
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. It should not be an absolute url.
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
 
@@ -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
- def protected_fields_for(class_name, permission_strings, client: nil)
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
- return EMPTY_SET if entry.kind == :unresolvable
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` emits a
114
- # one-time warning if it is handed a Marshal-serializing store.
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
- def initialize(moneta, ttl: nil, namespace: "emb:")
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
- warn "[Parse::Embeddings::Cache::MonetaStore] SECURITY: the supplied Moneta " \
141
- "store deserializes values with Marshal. A cache read Marshal.loads bytes " \
142
- "from the backing store, which is a remote-code-execution vector when the " \
143
- "store is shared/untrusted. Rebuild it with value_serializer: nil, e.g. " \
144
- "Moneta.new(:Redis, url: ..., value_serializer: nil)."
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
- moneta.class.name.to_s.include?("MarshalValue")
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
- def self.generate_all(model_classes)
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]).populate_fields
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
- def self.generate(model_class, registry: nil)
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).populate_fields
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?