parse-stack-next 5.2.1 → 5.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.bundle/config +1 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +616 -0
- data/Gemfile +7 -0
- data/Gemfile.lock +12 -4
- data/README.md +296 -3
- data/Rakefile +243 -41
- data/docs/atlas_vector_search_guide.md +86 -2
- data/docs/client_sdk_guide.md +38 -0
- data/docs/mcp_guide.md +119 -4
- data/docs/mongodb_direct_guide.md +93 -1
- data/docs/usage_guide.md +11 -1
- data/docs/webhooks_guide.md +418 -0
- data/examples/README.md +46 -0
- data/examples/basic_client.rb +93 -0
- data/examples/basic_server.rb +109 -0
- data/examples/live_query_listener.rb +98 -0
- data/examples/rag_chatbot.rb +221 -0
- data/examples/webhook_server.rb +111 -0
- data/lib/parse/agent/mcp_rack_app.rb +285 -62
- data/lib/parse/agent/tools.rb +45 -5
- data/lib/parse/api/aggregate.rb +7 -1
- data/lib/parse/api/cloud_functions.rb +12 -4
- data/lib/parse/api/hooks.rb +46 -9
- data/lib/parse/api/objects.rb +16 -2
- data/lib/parse/api/path_segment.rb +33 -0
- data/lib/parse/api/server.rb +94 -0
- data/lib/parse/api/users.rb +58 -2
- data/lib/parse/atlas_search.rb +7 -7
- data/lib/parse/client/body_builder.rb +5 -0
- data/lib/parse/client/protocol.rb +4 -0
- data/lib/parse/client.rb +174 -9
- data/lib/parse/embeddings/spend_cap.rb +255 -0
- data/lib/parse/embeddings.rb +1 -0
- data/lib/parse/live_query/client.rb +3 -1
- data/lib/parse/live_query/subscription.rb +32 -5
- data/lib/parse/model/acl.rb +4 -2
- data/lib/parse/model/associations/belongs_to.rb +47 -0
- data/lib/parse/model/classes/audience.rb +52 -4
- data/lib/parse/model/classes/user.rb +200 -3
- data/lib/parse/model/core/embed_managed.rb +113 -0
- data/lib/parse/model/core/pluralized_aliases.rb +30 -0
- data/lib/parse/model/core/properties.rb +27 -0
- data/lib/parse/model/core/querying.rb +73 -1
- data/lib/parse/model/core/vector_searchable.rb +161 -0
- data/lib/parse/model/file.rb +35 -2
- data/lib/parse/model/object.rb +28 -5
- data/lib/parse/mongodb.rb +7 -1
- data/lib/parse/pipeline_security.rb +5 -3
- data/lib/parse/query/constraints.rb +29 -0
- data/lib/parse/query.rb +265 -27
- data/lib/parse/retrieval/agent_tool.rb +49 -0
- data/lib/parse/retrieval/reranker/cohere.rb +218 -0
- data/lib/parse/retrieval/reranker.rb +157 -0
- data/lib/parse/retrieval/retriever.rb +110 -23
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +173 -1
- data/lib/parse/two_factor_auth/user_extension.rb +123 -31
- data/lib/parse/vector_search/hybrid.rb +578 -0
- data/lib/parse/webhooks/payload.rb +399 -11
- data/lib/parse/webhooks/trigger_audit.rb +502 -0
- data/lib/parse/webhooks.rb +215 -3
- data/scripts/docker/Dockerfile.parse +5 -1
- data/scripts/docker/docker-compose.test.yml +31 -0
- data/scripts/docker/docker-compose.verifyemail.yml +4 -0
- data/scripts/docker/preflight.sh +76 -0
- data/scripts/start-parse.sh +52 -4
- metadata +16 -1
data/lib/parse/stack.rb
CHANGED
|
@@ -548,6 +548,21 @@ module Parse
|
|
|
548
548
|
# PARSE_STRICT_POINTER_SHAPES=true
|
|
549
549
|
@strict_pointer_shapes = ENV["PARSE_STRICT_POINTER_SHAPES"] == "true"
|
|
550
550
|
|
|
551
|
+
# Configuration for automatic pluralized class-name aliases. When enabled
|
|
552
|
+
# (the default), referencing the plural form of a {Parse::Object} subclass
|
|
553
|
+
# constant resolves to that class, so `Posts.where(...)` works for a class
|
|
554
|
+
# `Post`. The alias is created lazily on first reference via `const_missing`
|
|
555
|
+
# and points at the same class object, so every class method
|
|
556
|
+
# (`where`, `query`, `count`, `find`, `all`, scopes) works for free and
|
|
557
|
+
# `Posts.parse_class` still returns `"Post"`. Classes whose name already
|
|
558
|
+
# ends in `s` are skipped. Set to false to opt out globally.
|
|
559
|
+
# @example Opt out globally
|
|
560
|
+
# Parse.pluralized_aliases = false
|
|
561
|
+
# @example ENV opt-out
|
|
562
|
+
# PARSE_PLURALIZED_ALIASES=false
|
|
563
|
+
# @see Parse::Core::Querying#pluralized_alias!
|
|
564
|
+
@pluralized_aliases = ENV["PARSE_PLURALIZED_ALIASES"] != "false"
|
|
565
|
+
|
|
551
566
|
# Tuning bundle for the synchronize-create lock. Per-call kwargs override.
|
|
552
567
|
# Keys: :ttl (seconds, default 3, max 30), :wait (seconds, default 2.0,
|
|
553
568
|
# max 30), :on_degraded (:warn, :warn_throttled, :raise, :proceed).
|
|
@@ -630,7 +645,8 @@ module Parse
|
|
|
630
645
|
:rewrite_lookups, :strict_property_redefinition,
|
|
631
646
|
:synchronize_create_default, :synchronize_create_options, :synchronize_create_secret,
|
|
632
647
|
:synchronize_create_store, :synchronize_classes,
|
|
633
|
-
:strict_pointer_shapes, :suppress_server_version_warning
|
|
648
|
+
:strict_pointer_shapes, :suppress_server_version_warning,
|
|
649
|
+
:pluralized_aliases
|
|
634
650
|
|
|
635
651
|
# Check whether the Parse Server version deprecation warning is
|
|
636
652
|
# silenced. Returns true if either the in-process accessor or the
|
|
@@ -714,6 +730,140 @@ module Parse
|
|
|
714
730
|
@strict_pointer_shapes == true
|
|
715
731
|
end
|
|
716
732
|
|
|
733
|
+
# Whether automatic pluralized class-name aliases are enabled. Defaults
|
|
734
|
+
# to true; opt out with `Parse.pluralized_aliases = false` or
|
|
735
|
+
# `PARSE_PLURALIZED_ALIASES=false`. See {Parse.pluralized_aliases}.
|
|
736
|
+
# @return [Boolean]
|
|
737
|
+
def pluralized_aliases?
|
|
738
|
+
@pluralized_aliases != false
|
|
739
|
+
end
|
|
740
|
+
|
|
741
|
+
# @!visibility private
|
|
742
|
+
# Resolve a (possibly plural) missing constant to its singular
|
|
743
|
+
# {Parse::Object} subclass and install the alias on the referencing
|
|
744
|
+
# module. Returns the class when an alias was created, otherwise nil so
|
|
745
|
+
# the caller (`const_missing`) can fall through to `super` and preserve
|
|
746
|
+
# normal `NameError` / autoloading behavior.
|
|
747
|
+
#
|
|
748
|
+
# Guards (fail-through to nil unless ALL hold):
|
|
749
|
+
# - the feature is enabled,
|
|
750
|
+
# - {Parse::Object} is loaded,
|
|
751
|
+
# - the name singularizes to a *different* string (i.e. looks plural),
|
|
752
|
+
# - the singular form does NOT already end in `s` (per design: classes
|
|
753
|
+
# whose name ends in `s` are not auto-aliased),
|
|
754
|
+
# - the singular constant is defined (searching ancestors so a
|
|
755
|
+
# top-level model is visible from a nested reference) and is a
|
|
756
|
+
# `Parse::Object` subclass,
|
|
757
|
+
# - the plural is not already defined on the referencing module.
|
|
758
|
+
#
|
|
759
|
+
# @param mod [Module] the module/class on which `const_missing` fired.
|
|
760
|
+
# @param name [Symbol] the missing constant name.
|
|
761
|
+
# @return [Class, nil]
|
|
762
|
+
def __pluralized_alias_for(mod, name)
|
|
763
|
+
return nil unless pluralized_aliases?
|
|
764
|
+
return nil unless defined?(Parse::Object)
|
|
765
|
+
str = name.to_s
|
|
766
|
+
singular = str.singularize
|
|
767
|
+
return nil if singular == str
|
|
768
|
+
return nil if singular.end_with?("s")
|
|
769
|
+
sym = singular.to_sym
|
|
770
|
+
return nil unless mod.const_defined?(sym, true)
|
|
771
|
+
klass = mod.const_get(sym)
|
|
772
|
+
return nil unless klass.is_a?(Class) && klass < Parse::Object
|
|
773
|
+
return nil if mod.const_defined?(name, false)
|
|
774
|
+
mod.const_set(name, klass)
|
|
775
|
+
klass
|
|
776
|
+
rescue NameError, LoadError
|
|
777
|
+
# const_get/const_defined? can raise on malformed names or autoload
|
|
778
|
+
# failures; never let alias resolution mask the original lookup.
|
|
779
|
+
nil
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# Verify that every association target across the loaded {Parse::Object}
|
|
783
|
+
# subclasses resolves to a known Parse class. Covers `belongs_to` and
|
|
784
|
+
# `property … as:` pointer targets (via each class's `references`),
|
|
785
|
+
# `has_many … through: :relation` targets (via `relations`), and the
|
|
786
|
+
# query- and array-backed `has_many` targets (via `has_many_associations`)
|
|
787
|
+
# — the bucket where an `as:` typo otherwise stays latent until the
|
|
788
|
+
# association is first traversed at call time.
|
|
789
|
+
#
|
|
790
|
+
# This is the deferred companion to the definition-time scalar guard in
|
|
791
|
+
# {Parse::Associations::BelongsTo::ClassMethods#belongs_to}: at declaration
|
|
792
|
+
# time a forward reference (a target class that is required later) is legal
|
|
793
|
+
# and indistinguishable from a typo, so the cross-class resolution check is
|
|
794
|
+
# run here — after all models are loaded. Intended to run once at boot, in
|
|
795
|
+
# CI, or from a rake task ("during the upgrade").
|
|
796
|
+
#
|
|
797
|
+
# A target resolves when it is a Parse system class (`_User`, `_Role`,
|
|
798
|
+
# `_Installation`, `_Session`, …) or a registered {Parse::Object} subclass
|
|
799
|
+
# (via {Parse::Model.find_class}). Note this checks against *loaded Ruby
|
|
800
|
+
# models*: if you intentionally point at a server-side class that has no
|
|
801
|
+
# Ruby model, define a stub model for it or exclude it via `classes:`.
|
|
802
|
+
#
|
|
803
|
+
# @param classes [Array<Class>, nil] optional subset of Parse::Object
|
|
804
|
+
# subclasses to check; defaults to every loaded subclass.
|
|
805
|
+
# @raise [ArgumentError] if any target is unresolved, listing each
|
|
806
|
+
# offending `Class#field -> 'Target'`.
|
|
807
|
+
# @return [true] when every association target resolves.
|
|
808
|
+
def validate_associations!(classes: nil)
|
|
809
|
+
models = classes || Parse::Object.descendants
|
|
810
|
+
problems = []
|
|
811
|
+
models.each do |klass|
|
|
812
|
+
next unless klass.respond_to?(:parse_class)
|
|
813
|
+
if klass.respond_to?(:references)
|
|
814
|
+
klass.references.each do |field, target|
|
|
815
|
+
next if _association_target_resolvable?(target)
|
|
816
|
+
# `references` is keyed by the remote (camelCase) column; report the
|
|
817
|
+
# declared Ruby accessor so the operator can find the offending line.
|
|
818
|
+
accessor = (klass.respond_to?(:field_map) && klass.field_map.key(field)) || field
|
|
819
|
+
problems << "#{klass}##{accessor} -> #{target.inspect} (no such Parse class)"
|
|
820
|
+
end
|
|
821
|
+
end
|
|
822
|
+
if klass.respond_to?(:relations)
|
|
823
|
+
klass.relations.each do |field, target|
|
|
824
|
+
next if _association_target_resolvable?(target)
|
|
825
|
+
problems << "#{klass}##{field} (relation) -> #{target.inspect} (no such Parse class)"
|
|
826
|
+
end
|
|
827
|
+
end
|
|
828
|
+
if klass.respond_to?(:has_many_associations)
|
|
829
|
+
klass.has_many_associations.each do |accessor, meta|
|
|
830
|
+
# `:relation`-storage has_many is mirrored into `relations` and is
|
|
831
|
+
# already reported above; only the `:query` and `:array` storage
|
|
832
|
+
# targets (which live nowhere else) need checking here. This is the
|
|
833
|
+
# branch where a `has_many … as:` typo hides, since a query-backed
|
|
834
|
+
# has_many resolves its target lazily at call time.
|
|
835
|
+
next if meta[:storage] == :relation
|
|
836
|
+
target = meta[:target_class]
|
|
837
|
+
next if target.nil? || _association_target_resolvable?(target)
|
|
838
|
+
problems << "#{klass}##{accessor} (has_many #{meta[:storage]}) -> " \
|
|
839
|
+
"#{target.inspect} (no such Parse class)"
|
|
840
|
+
end
|
|
841
|
+
end
|
|
842
|
+
end
|
|
843
|
+
unless problems.empty?
|
|
844
|
+
raise ArgumentError,
|
|
845
|
+
"Unresolved Parse association targets:\n " + problems.join("\n ") +
|
|
846
|
+
"\nRequire/define the target class, or fix the `as:`/`class_name:` name."
|
|
847
|
+
end
|
|
848
|
+
true
|
|
849
|
+
end
|
|
850
|
+
|
|
851
|
+
# @!visibility private
|
|
852
|
+
# Whether an association target class name resolves to a known Parse
|
|
853
|
+
# class. Parse system classes resolve against {Parse::Model::SYSTEM_CLASS_MAP}
|
|
854
|
+
# — both the canonical `_`-prefixed value (`_User`) and the bare-name key
|
|
855
|
+
# (`User`) — even when their Ruby class is not loaded; everything else must
|
|
856
|
+
# resolve via {Parse::Model.find_class}. A leading underscore is NOT a
|
|
857
|
+
# blanket pass: a typo'd system name such as `_Usr` is neither in the map
|
|
858
|
+
# nor a registered model, so it is still surfaced as unresolved.
|
|
859
|
+
def _association_target_resolvable?(target)
|
|
860
|
+
name = target.to_s
|
|
861
|
+
return false if name.empty?
|
|
862
|
+
return true if Parse::Model::SYSTEM_CLASS_MAP.key?(name) ||
|
|
863
|
+
Parse::Model::SYSTEM_CLASS_MAP.value?(name)
|
|
864
|
+
!Parse::Model.find_class(name).nil?
|
|
865
|
+
end
|
|
866
|
+
|
|
717
867
|
# Check if MCP server feature is enabled
|
|
718
868
|
# Requires PARSE_MCP_ENABLED=true in environment AND Parse.mcp_server_enabled = true
|
|
719
869
|
# @return [Boolean]
|
|
@@ -790,6 +940,23 @@ module Parse
|
|
|
790
940
|
end
|
|
791
941
|
Parse.client.send_analytics(event_name, dimensions, **opts)
|
|
792
942
|
end
|
|
943
|
+
|
|
944
|
+
# Capability probe against the connected Parse Server, delegated to the
|
|
945
|
+
# default client. Builds on the memoized `serverInfo` fetch — see
|
|
946
|
+
# {Parse::API::Server#server_supports?} for the capability table and the
|
|
947
|
+
# fail-open-to-modern semantics.
|
|
948
|
+
# @param feature [Symbol] a capability key.
|
|
949
|
+
# @return [Boolean] whether the connected server supports the feature.
|
|
950
|
+
def server_supports?(feature)
|
|
951
|
+
Parse.client.server_supports?(feature)
|
|
952
|
+
end
|
|
953
|
+
|
|
954
|
+
# The coarse `features` block advertised by `GET /serverInfo`, delegated
|
|
955
|
+
# to the default client. @see Parse::API::Server#server_features
|
|
956
|
+
# @return [Hash] the advertised features block, or `{}` if unavailable.
|
|
957
|
+
def server_features
|
|
958
|
+
Parse.client.server_features
|
|
959
|
+
end
|
|
793
960
|
end
|
|
794
961
|
|
|
795
962
|
# Error raised when {Parse::CreateLock#synchronize} cannot acquire the
|
|
@@ -851,4 +1018,9 @@ end
|
|
|
851
1018
|
# the setter on load.
|
|
852
1019
|
Parse._attach_slow_query_subscriber! if Parse.slow_query_threshold_ms
|
|
853
1020
|
|
|
1021
|
+
# Install the lazy pluralized class-name alias hook (Posts -> Post). Loaded
|
|
1022
|
+
# last so Parse::Object and the Parse.__pluralized_alias_for helper are
|
|
1023
|
+
# already defined. Gated at runtime on Parse.pluralized_aliases?.
|
|
1024
|
+
require_relative "model/core/pluralized_aliases"
|
|
1025
|
+
|
|
854
1026
|
require_relative "stack/railtie" if defined?(::Rails)
|
|
@@ -155,7 +155,7 @@ module Parse
|
|
|
155
155
|
},
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
response = client.update_user(id, { authData: auth_data_payload },
|
|
158
|
+
response = client.update_user(id, { authData: auth_data_payload }, session_token: session_token)
|
|
159
159
|
|
|
160
160
|
if response.error?
|
|
161
161
|
if response.result.to_s.include?("Invalid MFA")
|
|
@@ -208,7 +208,7 @@ module Parse
|
|
|
208
208
|
},
|
|
209
209
|
}
|
|
210
210
|
|
|
211
|
-
response = client.update_user(id, { authData: auth_data_payload },
|
|
211
|
+
response = client.update_user(id, { authData: auth_data_payload }, session_token: session_token)
|
|
212
212
|
|
|
213
213
|
if response.error?
|
|
214
214
|
raise Parse::Client::ResponseError, response
|
|
@@ -245,7 +245,7 @@ module Parse
|
|
|
245
245
|
},
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
response = client.update_user(id, { authData: auth_data_payload },
|
|
248
|
+
response = client.update_user(id, { authData: auth_data_payload }, session_token: session_token)
|
|
249
249
|
|
|
250
250
|
if response.error?
|
|
251
251
|
if response.result.to_s.include?("Invalid MFA token")
|
|
@@ -276,27 +276,60 @@ module Parse
|
|
|
276
276
|
raise MFA::NotEnabledError, "MFA is not enabled for this user" unless mfa_enabled?
|
|
277
277
|
raise ArgumentError, "Current token is required" if current_token.blank?
|
|
278
278
|
|
|
279
|
-
#
|
|
280
|
-
#
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
279
|
+
# Parse Server's TOTP adapter exposes no first-class "disable via authData
|
|
280
|
+
# update" path — its validateUpdate always re-runs setup, so a partial
|
|
281
|
+
# mfa payload is rejected outright. Disabling is therefore a two-step:
|
|
282
|
+
#
|
|
283
|
+
# 1. Prove possession of the current code by submitting it as
|
|
284
|
+
# `{ mfa: { old: <token> } }`. In the *update* context (unlike a
|
|
285
|
+
# fresh login) the adapter validates that code against the stored
|
|
286
|
+
# secret. A WRONG code fails at validateLogin ("Invalid MFA token");
|
|
287
|
+
# a CORRECT code passes validateLogin and is then blocked by the
|
|
288
|
+
# re-setup requirement ("Invalid MFA data") — which is precisely the
|
|
289
|
+
# signal that the code was accepted. (This re-entry of the current
|
|
290
|
+
# code is the deliberate confirmation gate for turning MFA off.)
|
|
291
|
+
# 2. Disable MFA by unlinking the provider with `{ mfa: nil }`.
|
|
292
|
+
#
|
|
293
|
+
# This keeps self-disable gated on a valid current code even though the
|
|
294
|
+
# server offers no dedicated TOTP self-disable endpoint.
|
|
295
|
+
verify = client.update_user(id, { authData: { mfa: { old: current_token } } },
|
|
296
|
+
session_token: session_token)
|
|
297
|
+
# Classify the two-step response POSITIVELY instead of treating
|
|
298
|
+
# "anything that isn't success-or-one-magic-string" as a bad
|
|
299
|
+
# token. The current code is ACCEPTED iff the server either
|
|
300
|
+
# succeeds or rejects only the follow-on re-setup ("Invalid MFA
|
|
301
|
+
# data") — that block fires AFTER validateLogin has already
|
|
302
|
+
# accepted the code. A WRONG code fails earlier at validateLogin
|
|
303
|
+
# ("Invalid MFA token"). Any OTHER error (transport, session, 5xx)
|
|
304
|
+
# is a real fault surfaced as-is, not mislabeled a verification
|
|
305
|
+
# failure.
|
|
306
|
+
err = verify.error.to_s
|
|
307
|
+
code_rejected = err.match?(/Invalid MFA token/i)
|
|
308
|
+
code_accepted = verify.success? || err.match?(/Invalid MFA data/i)
|
|
309
|
+
if code_rejected
|
|
310
|
+
raise MFA::VerificationError, "Invalid MFA token"
|
|
311
|
+
elsif !code_accepted
|
|
312
|
+
raise Parse::Client::ResponseError, verify
|
|
295
313
|
end
|
|
296
314
|
|
|
297
|
-
|
|
298
|
-
|
|
315
|
+
response = client.update_user(id, { authData: { mfa: nil } }, session_token: session_token)
|
|
316
|
+
raise Parse::Client::ResponseError, response if response.error?
|
|
317
|
+
|
|
318
|
+
# CONFIRM the disable took effect from the SERVER's own view — a
|
|
319
|
+
# positive post-condition rather than trusting the unlink response
|
|
320
|
+
# alone. We must read the server directly here, NOT lean on the
|
|
321
|
+
# in-memory #mfa_enabled? projection: Parse Server omits +authData+
|
|
322
|
+
# entirely for a user with no providers, so once MFA is unlinked an
|
|
323
|
+
# ordinary fetch carries no +authData+ key at all and therefore can
|
|
324
|
+
# never clear the +{ mfa: { status: "enabled" } }+ value pinned at
|
|
325
|
+
# enrollment. An enabled account's own (session-token) read returns
|
|
326
|
+
# +authData.mfa+; a disabled one omits it — so an absent/mfa-less
|
|
327
|
+
# authData on this trusted self-read is the authoritative signal.
|
|
328
|
+
if mfa_enabled_on_server?
|
|
329
|
+
raise MFA::VerificationError, "MFA disable did not take effect (still enabled after unlink)"
|
|
330
|
+
end
|
|
299
331
|
|
|
332
|
+
clear_local_mfa_projection!
|
|
300
333
|
true
|
|
301
334
|
end
|
|
302
335
|
|
|
@@ -315,20 +348,26 @@ module Parse
|
|
|
315
348
|
#
|
|
316
349
|
# @param authorized_by [Parse::User, Parse::Pointer] the operator
|
|
317
350
|
# performing the override. Required.
|
|
318
|
-
# @param admin_role [Parse::Role, String, nil]
|
|
319
|
-
#
|
|
351
|
+
# @param admin_role [Parse::Role, String, nil] role (or role name)
|
|
352
|
+
# that +authorized_by+ must belong to. Library-enforced. Either
|
|
353
|
+
# this or +allow_unverified: true+ is REQUIRED (fail-closed).
|
|
354
|
+
# @param allow_unverified [Boolean] explicitly accept caller-side
|
|
355
|
+
# authorization without a library role check. Defaults to +false+;
|
|
356
|
+
# must be set deliberately to bypass MFA without an +admin_role+.
|
|
320
357
|
# @return [Boolean] True if disabled successfully.
|
|
321
358
|
# @raise [ArgumentError] when +authorized_by:+ is missing or not a User.
|
|
322
|
-
# @raise [Parse::MFA::ForbiddenError] when +admin_role+
|
|
359
|
+
# @raise [Parse::MFA::ForbiddenError] when neither +admin_role+ nor
|
|
360
|
+
# +allow_unverified:+ is supplied, or when +admin_role+ is supplied
|
|
323
361
|
# and the operator is not a member.
|
|
324
362
|
#
|
|
325
|
-
# @example
|
|
326
|
-
# user.disable_mfa_master_key!(authorized_by: current_admin)
|
|
327
|
-
#
|
|
328
|
-
# @example Library-enforced role check
|
|
363
|
+
# @example Library-enforced role check (preferred)
|
|
329
364
|
# user.disable_mfa_master_key!(authorized_by: current_admin,
|
|
330
365
|
# admin_role: "Admin")
|
|
331
|
-
|
|
366
|
+
#
|
|
367
|
+
# @example Caller-verified authorization (explicit opt-out)
|
|
368
|
+
# user.disable_mfa_master_key!(authorized_by: current_admin,
|
|
369
|
+
# allow_unverified: true)
|
|
370
|
+
def disable_mfa_master_key!(authorized_by:, admin_role: nil, allow_unverified: false)
|
|
332
371
|
operator = authorized_by
|
|
333
372
|
unless operator.is_a?(Parse::User) ||
|
|
334
373
|
(operator.is_a?(Parse::Pointer) && operator.parse_class == Parse::User.parse_class)
|
|
@@ -340,6 +379,18 @@ module Parse
|
|
|
340
379
|
raise ArgumentError, "authorized_by: User must be persisted (have an objectId)"
|
|
341
380
|
end
|
|
342
381
|
|
|
382
|
+
# FAIL CLOSED: this method bypasses MFA verification entirely via
|
|
383
|
+
# the master key, so it refuses to run without SOME authorization
|
|
384
|
+
# signal. Either supply an `admin_role:` for the library to verify,
|
|
385
|
+
# or pass `allow_unverified: true` to deliberately assert that the
|
|
386
|
+
# caller has already authorized the operator out-of-band.
|
|
387
|
+
if admin_role.nil? && !allow_unverified
|
|
388
|
+
raise MFA::ForbiddenError,
|
|
389
|
+
"disable_mfa_master_key! refuses to bypass MFA without an authorization " \
|
|
390
|
+
"check: pass admin_role: to enforce role membership, or " \
|
|
391
|
+
"allow_unverified: true to explicitly accept caller-side authorization."
|
|
392
|
+
end
|
|
393
|
+
|
|
343
394
|
if admin_role
|
|
344
395
|
role = admin_role.is_a?(Parse::Role) ? admin_role : Parse::Role.find_by_name(admin_role.to_s)
|
|
345
396
|
if role.nil?
|
|
@@ -357,14 +408,19 @@ module Parse
|
|
|
357
408
|
end
|
|
358
409
|
|
|
359
410
|
auth_data_payload = { mfa: nil }
|
|
360
|
-
response = client.update_user(id, { authData: auth_data_payload },
|
|
411
|
+
response = client.update_user(id, { authData: auth_data_payload }, use_master_key: true)
|
|
361
412
|
|
|
362
413
|
if response.error?
|
|
363
414
|
raise Parse::Client::ResponseError, response
|
|
364
415
|
end
|
|
365
416
|
|
|
366
|
-
# Refresh auth_data
|
|
417
|
+
# Refresh auth_data, then drop the in-memory MFA projection. As in
|
|
418
|
+
# #disable_mfa!, a disabled user's read omits +authData+, so the
|
|
419
|
+
# +{ mfa: { status: "enabled" } }+ value pinned at enrollment won't
|
|
420
|
+
# self-clear on fetch — clear it explicitly so #mfa_enabled? reports
|
|
421
|
+
# the truth after a master-key disable.
|
|
367
422
|
fetch
|
|
423
|
+
clear_local_mfa_projection!
|
|
368
424
|
|
|
369
425
|
true
|
|
370
426
|
end
|
|
@@ -435,6 +491,42 @@ module Parse
|
|
|
435
491
|
account_name = email.presence || username.presence || id
|
|
436
492
|
MFA.qr_code(secret, account_name, issuer: issuer, format: format)
|
|
437
493
|
end
|
|
494
|
+
|
|
495
|
+
private
|
|
496
|
+
|
|
497
|
+
# @!visibility private
|
|
498
|
+
# Authoritative server-side MFA check via a trusted self-read.
|
|
499
|
+
# Reads +authData.mfa+ straight from a fresh session-token fetch
|
|
500
|
+
# rather than the (possibly stale) in-memory projection. An enabled
|
|
501
|
+
# account returns +authData.mfa+ with a +status+/+secret+; a disabled
|
|
502
|
+
# one omits +authData+ — so absence (or an mfa-less authData) means
|
|
503
|
+
# disabled.
|
|
504
|
+
# @return [Boolean]
|
|
505
|
+
def mfa_enabled_on_server?
|
|
506
|
+
result = client.fetch_object(self.class.parse_class, id,
|
|
507
|
+
session_token: session_token).result
|
|
508
|
+
mfa = result.is_a?(Hash) ? result["authData"] : nil
|
|
509
|
+
mfa = mfa["mfa"] if mfa.is_a?(Hash)
|
|
510
|
+
mfa.is_a?(Hash) && (mfa["status"] == "enabled" || mfa["secret"].present?)
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# @!visibility private
|
|
514
|
+
# Drop the in-memory MFA projection after a disable. A disabled user's
|
|
515
|
+
# server read omits +authData+ entirely, so an ordinary fetch can
|
|
516
|
+
# never clear the +{ mfa: { status: "enabled" } }+ value pinned at
|
|
517
|
+
# enrollment; do it explicitly here. Only the +mfa+ subkey is removed
|
|
518
|
+
# (any anonymous/OAuth authData is preserved), and the assignment runs
|
|
519
|
+
# through the non-dirtying hydration path inside a +with_authdata_trust+
|
|
520
|
+
# scope so it is neither stripped nor marked dirty — a later #save will
|
|
521
|
+
# not resend +authData+.
|
|
522
|
+
def clear_local_mfa_projection!
|
|
523
|
+
cleared = auth_data.is_a?(Hash) ? auth_data.dup : {}
|
|
524
|
+
cleared.delete("mfa")
|
|
525
|
+
cleared.delete(:mfa)
|
|
526
|
+
self.class.with_authdata_trust do
|
|
527
|
+
apply_attributes!({ "authData" => cleared }, dirty_track: false)
|
|
528
|
+
end
|
|
529
|
+
end
|
|
438
530
|
end
|
|
439
531
|
|
|
440
532
|
# Not enabled error
|