parse-stack-next 5.7.6 → 5.8.1
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 +1148 -0
- data/README.md +39 -23
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +181 -13
- data/docs/caching.md +50 -0
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +332 -9
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +126 -17
- data/lib/parse/acl_scope.rb +230 -50
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +11 -3
- data/lib/parse/agent/field_names.rb +53 -0
- data/lib/parse/agent/field_policy.rb +74 -0
- data/lib/parse/agent/mcp_deployments.rb +426 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_rack_app.rb +424 -45
- data/lib/parse/agent/mcp_server.rb +23 -1
- data/lib/parse/agent/mcp_subscriptions.rb +124 -6
- data/lib/parse/agent/metadata_registry.rb +67 -8
- data/lib/parse/agent/prompt_hardening.rb +9 -3
- data/lib/parse/agent/tools.rb +378 -29
- data/lib/parse/agent.rb +298 -21
- data/lib/parse/api/batch.rb +10 -1
- data/lib/parse/api/schema.rb +23 -4
- data/lib/parse/api/sessions.rb +6 -2
- data/lib/parse/api/users.rb +88 -14
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +144 -24
- data/lib/parse/authorization.rb +289 -4
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +264 -35
- data/lib/parse/client/body_builder.rb +21 -0
- data/lib/parse/client/caching.rb +371 -27
- data/lib/parse/client/request.rb +26 -14
- data/lib/parse/client/response.rb +49 -6
- data/lib/parse/client.rb +227 -40
- data/lib/parse/clp_scope.rb +317 -25
- data/lib/parse/console.rb +2 -2
- data/lib/parse/embeddings/voyage.rb +181 -17
- data/lib/parse/graphql/type_generator.rb +3 -0
- data/lib/parse/live_query/client.rb +72 -2
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +146 -17
- data/lib/parse/model/associations/has_many.rb +49 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +145 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +229 -38
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +95 -0
- data/lib/parse/model/classes/session.rb +304 -1
- data/lib/parse/model/classes/user.rb +104 -8
- data/lib/parse/model/core/actions.rb +287 -70
- data/lib/parse/model/core/builder.rb +100 -8
- data/lib/parse/model/core/create_lock.rb +27 -2
- data/lib/parse/model/core/describe.rb +2 -0
- data/lib/parse/model/core/fetching.rb +50 -5
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/core/pluralized_aliases.rb +8 -4
- data/lib/parse/model/core/properties.rb +488 -39
- data/lib/parse/model/core/querying.rb +7 -0
- data/lib/parse/model/core/schema.rb +5 -3
- data/lib/parse/model/core/search_indexing.rb +63 -0
- data/lib/parse/model/core/vector_searchable.rb +35 -6
- data/lib/parse/model/file.rb +9 -2
- data/lib/parse/model/geopoint.rb +61 -13
- data/lib/parse/model/model.rb +160 -9
- data/lib/parse/model/object.rb +317 -18
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +189 -61
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +31 -1
- data/lib/parse/query/constraints.rb +89 -59
- data/lib/parse/query/cursor.rb +3 -1
- data/lib/parse/query/operation.rb +62 -8
- data/lib/parse/query/ordering.rb +34 -6
- data/lib/parse/query.rb +1871 -167
- data/lib/parse/retrieval/agent_tool.rb +225 -8
- data/lib/parse/retrieval/benchmark.rb +149 -0
- data/lib/parse/retrieval/profiles.rb +320 -0
- data/lib/parse/retrieval/retriever.rb +10 -1
- data/lib/parse/retrieval.rb +2 -0
- data/lib/parse/schema/search_index_migrator.rb +23 -5
- data/lib/parse/schema.rb +74 -18
- data/lib/parse/stack/tasks.rb +6 -4
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +91 -14
- data/lib/parse/two_factor_auth/user_extension.rb +14 -2
- data/lib/parse/two_factor_auth.rb +11 -0
- data/lib/parse/vector_search/hybrid.rb +110 -33
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +259 -23
- data/lib/parse/webhooks/payload.rb +136 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +715 -40
- metadata +8 -1
data/lib/parse/atlas_search.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "atlas_search/index_manager"
|
|
|
8
8
|
require_relative "atlas_search/search_builder"
|
|
9
9
|
require_relative "atlas_search/result"
|
|
10
10
|
require_relative "atlas_search/session"
|
|
11
|
+
require_relative "atlas_search/protected_paths"
|
|
11
12
|
|
|
12
13
|
module Parse
|
|
13
14
|
# Atlas Search module for MongoDB Atlas full-text search capabilities.
|
|
@@ -363,6 +364,7 @@ module Parse
|
|
|
363
364
|
|
|
364
365
|
index_name = options[:index] || @default_index
|
|
365
366
|
fields = normalize_fields(options[:fields])
|
|
367
|
+
assert_search_fields_allowed!(fields, protected_fields, resolution)
|
|
366
368
|
limit = options[:limit] || 100
|
|
367
369
|
skip_val = options[:skip] || 0
|
|
368
370
|
|
|
@@ -436,6 +438,15 @@ module Parse
|
|
|
436
438
|
collection_name, resolution.permission_strings,
|
|
437
439
|
)
|
|
438
440
|
assert_highlight_field_allowed!(options[:highlight_field], protected_fields, resolution)
|
|
441
|
+
# The caller built this stage, so any operator path, sort key, or
|
|
442
|
+
# highlight inside it may name a protected field. Walk it and
|
|
443
|
+
# refuse before it runs: a protected field that decides matches
|
|
444
|
+
# or ranking leaks its value even though the output strips it.
|
|
445
|
+
ProtectedPaths.assert_search_stage_allowed!(
|
|
446
|
+
search_stage, protected_fields, resolution,
|
|
447
|
+
collection_name: collection_name,
|
|
448
|
+
method_name: "Parse::AtlasSearch.search_with_stage",
|
|
449
|
+
)
|
|
439
450
|
|
|
440
451
|
search_pipeline!(
|
|
441
452
|
collection_name, search_stage,
|
|
@@ -512,7 +523,8 @@ module Parse
|
|
|
512
523
|
collection_name, resolution.permission_strings,
|
|
513
524
|
)
|
|
514
525
|
field_str = field.to_s
|
|
515
|
-
if
|
|
526
|
+
if ProtectedPaths.enforce?(resolution, protected_fields) &&
|
|
527
|
+
ProtectedPaths.touches?(field_str, protected_fields)
|
|
516
528
|
raise Parse::CLPScope::Denied.new(
|
|
517
529
|
collection_name, :find,
|
|
518
530
|
"Parse::AtlasSearch.autocomplete refused: field '#{field_str}' is in " \
|
|
@@ -547,6 +559,7 @@ module Parse
|
|
|
547
559
|
unless resolution.master?
|
|
548
560
|
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
549
561
|
pipeline << acl_match if acl_match
|
|
562
|
+
pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
|
|
550
563
|
end
|
|
551
564
|
|
|
552
565
|
# Add filter if provided
|
|
@@ -648,6 +661,17 @@ module Parse
|
|
|
648
661
|
"accept that bucket counts include all rows, or use " \
|
|
649
662
|
"#search for ACL-scoped results without facets."
|
|
650
663
|
end
|
|
664
|
+
# Inside Parse.without_master_key the only scope left is public,
|
|
665
|
+
# and $searchMeta bucket counts are not ACL-filtered there either,
|
|
666
|
+
# so they would count rows the block is meant to hide. Fail closed.
|
|
667
|
+
if Parse::ACLScope.master_key_suppressed?
|
|
668
|
+
raise FacetedSearchNotACLSafe,
|
|
669
|
+
"Parse::AtlasSearch.faceted_search cannot run inside " \
|
|
670
|
+
"Parse.without_master_key: $searchMeta bucket counts include " \
|
|
671
|
+
"rows the caller cannot read. Wrap the call in " \
|
|
672
|
+
"Parse.with_master_key to accept master-key counts, or use " \
|
|
673
|
+
"#search for ACL-scoped results without facets."
|
|
674
|
+
end
|
|
651
675
|
# Wave-3b READPREF-4: see #search for rationale. Captured
|
|
652
676
|
# before resolve_scope! pops the auth kwargs so the recursive
|
|
653
677
|
# search() call below can re-thread it explicitly (resolve!
|
|
@@ -689,6 +713,21 @@ module Parse
|
|
|
689
713
|
end
|
|
690
714
|
end
|
|
691
715
|
|
|
716
|
+
# A non-master caller (the public fallback when no auth kwargs are
|
|
717
|
+
# passed) must not facet on, or text-match against, a protected
|
|
718
|
+
# field: bucket values and counts would reveal it directly, and a
|
|
719
|
+
# wildcard operator lets it decide which rows are counted.
|
|
720
|
+
unless resolution.master?
|
|
721
|
+
facet_protected = Parse::CLPScope.protected_fields_for(
|
|
722
|
+
collection_name, resolution.permission_strings,
|
|
723
|
+
)
|
|
724
|
+
ProtectedPaths.assert_search_stage_allowed!(
|
|
725
|
+
search_meta_stage, facet_protected, resolution,
|
|
726
|
+
collection_name: collection_name,
|
|
727
|
+
method_name: "Parse::AtlasSearch.faceted_search",
|
|
728
|
+
)
|
|
729
|
+
end
|
|
730
|
+
|
|
692
731
|
# Execute facet query. $searchMeta MUST be the only / first
|
|
693
732
|
# stage of its pipeline — Atlas rejects anything prepended.
|
|
694
733
|
# Bypass Parse::MongoDB.aggregate (which would prepend a
|
|
@@ -788,6 +827,7 @@ module Parse
|
|
|
788
827
|
unless resolution.master?
|
|
789
828
|
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
790
829
|
pipeline << acl_match if acl_match
|
|
830
|
+
pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
|
|
791
831
|
end
|
|
792
832
|
|
|
793
833
|
# Caller-supplied filter, sanitized against operator injection
|
|
@@ -797,6 +837,14 @@ module Parse
|
|
|
797
837
|
pipeline << { "$match" => mongo_filter }
|
|
798
838
|
end
|
|
799
839
|
|
|
840
|
+
# Sorting on a protected field orders rows by its value, which is
|
|
841
|
+
# the same oracle as matching on it.
|
|
842
|
+
if sort.is_a?(Hash) && !sort.empty?
|
|
843
|
+
ProtectedPaths.assert_paths_allowed!(
|
|
844
|
+
sort.keys.map(&:to_s), protected_fields, resolution,
|
|
845
|
+
collection_name: collection_name, what: "sort key",
|
|
846
|
+
)
|
|
847
|
+
end
|
|
800
848
|
pipeline << { "$sort" => (sort || { "_score" => -1 }) }
|
|
801
849
|
pipeline << { "$skip" => skip } if skip.to_i > 0
|
|
802
850
|
pipeline << { "$limit" => limit }
|
|
@@ -816,6 +864,8 @@ module Parse
|
|
|
816
864
|
unless resolution.master?
|
|
817
865
|
Parse::ACLScope.redact_results!(raw_results, resolution)
|
|
818
866
|
Parse::CLPScope.redact_protected_fields!(raw_results, protected_fields) if protected_fields.any?
|
|
867
|
+
# The pointerFields `$match` above already ran before `$limit`;
|
|
868
|
+
# this re-check is defense in depth and should drop nothing.
|
|
819
869
|
if pointer_fields
|
|
820
870
|
raw_results = Parse::CLPScope.filter_by_pointer_fields(
|
|
821
871
|
raw_results, pointer_fields, resolution.user_id,
|
|
@@ -937,13 +987,37 @@ module Parse
|
|
|
937
987
|
return Parse::ACLScope.resolve_for_role(acl_role, client: auth_client)
|
|
938
988
|
end
|
|
939
989
|
|
|
940
|
-
if master == true
|
|
990
|
+
if master == true && !Parse::ACLScope.master_key_suppressed?
|
|
941
991
|
return Parse::ACLScope::Resolution.new(
|
|
942
992
|
mode: :master, permission_strings: nil, user_id: nil, session: nil,
|
|
943
993
|
client: auth_client,
|
|
944
994
|
)
|
|
945
995
|
end
|
|
946
996
|
|
|
997
|
+
# Inside `Parse.without_master_key` an explicit master request runs
|
|
998
|
+
# in the public scope, as Parse::ACLScope.resolve! does: no
|
|
999
|
+
# no-ACL banner (the caller did pass a scope), and either strict
|
|
1000
|
+
# flag refuses the fallback.
|
|
1001
|
+
if master == true
|
|
1002
|
+
if @require_session_token == true || Parse::ACLScope.require_session_token == true
|
|
1003
|
+
raise ACLRequired,
|
|
1004
|
+
"Parse::AtlasSearch.#{method_name} was called with master: true " \
|
|
1005
|
+
"inside Parse.without_master_key, which drops master mode, and " \
|
|
1006
|
+
"require_session_token refuses the public fallback. Pass " \
|
|
1007
|
+
"session_token:, or run the call inside Parse.with_master_key " \
|
|
1008
|
+
"if it needs master authority."
|
|
1009
|
+
end
|
|
1010
|
+
anonymous = Session::Resolved.new(nil, Set.new)
|
|
1011
|
+
return Parse::ACLScope::Resolution.new(
|
|
1012
|
+
mode: :public,
|
|
1013
|
+
permission_strings: anonymous.permission_strings,
|
|
1014
|
+
user_id: nil,
|
|
1015
|
+
session: anonymous,
|
|
1016
|
+
client: auth_client,
|
|
1017
|
+
master_dropped: true,
|
|
1018
|
+
)
|
|
1019
|
+
end
|
|
1020
|
+
|
|
947
1021
|
if @require_session_token == true
|
|
948
1022
|
raise ACLRequired,
|
|
949
1023
|
"Parse::AtlasSearch.#{method_name} requires session_token: or " \
|
|
@@ -980,13 +1054,12 @@ module Parse
|
|
|
980
1054
|
# does inline (we can't reuse that path because of the $search-
|
|
981
1055
|
# at-stage-0 invariant).
|
|
982
1056
|
def assert_clp_find!(collection_name, resolution)
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
end
|
|
1057
|
+
# Same CLP branch evaluation as Parse::MongoDB.aggregate (public,
|
|
1058
|
+
# user, and role grants first; then pointerFields / readUserFields).
|
|
1059
|
+
# Raises Parse::CLPScope::Denied when the scope cannot find at all.
|
|
1060
|
+
Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
|
|
1061
|
+
label: "Atlas Search")
|
|
1062
|
+
nil
|
|
990
1063
|
end
|
|
991
1064
|
|
|
992
1065
|
# Resolve and return pointerFields for `find` on the collection.
|
|
@@ -994,17 +1067,22 @@ module Parse
|
|
|
994
1067
|
# current scope has no user_id (acl_role-only / public agents).
|
|
995
1068
|
# Returns nil when master-mode or no pointerFields entry exists.
|
|
996
1069
|
def resolve_pointer_fields!(collection_name, resolution)
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1070
|
+
# nil when a public, user, or role grant already permits every row
|
|
1071
|
+
# (Parse Server ignores pointer permissions then); otherwise the
|
|
1072
|
+
# pointerFields plus readUserFields the rows must match. The older
|
|
1073
|
+
# permits? / pointer_fields_for pair missed readUserFields entirely
|
|
1074
|
+
# and over-restricted a public grant that also listed pointerFields.
|
|
1075
|
+
Parse::CLPScope.row_constraint_for!(collection_name, :find, resolution,
|
|
1076
|
+
label: "Atlas Search")
|
|
1077
|
+
end
|
|
1078
|
+
|
|
1079
|
+
# The pointerFields / readUserFields ownership constraint as a
|
|
1080
|
+
# `$match` stage, placed after the ACL `$match` and before `$sort` /
|
|
1081
|
+
# `$limit`. Filtering the fetched rows afterwards returned short or
|
|
1082
|
+
# empty pages whenever the top-ranked hits belonged to other users,
|
|
1083
|
+
# even though eligible matches ranked below them.
|
|
1084
|
+
def pointer_fields_match_stage(pointer_fields, resolution)
|
|
1085
|
+
{ "$match" => Parse::CLPScope.pointer_fields_predicate(pointer_fields, resolution.user_id) }
|
|
1008
1086
|
end
|
|
1009
1087
|
|
|
1010
1088
|
# ATLAS-4: refuse `highlight_field:` when the field is in the
|
|
@@ -1016,8 +1094,8 @@ module Parse
|
|
|
1016
1094
|
return if highlight_field.nil?
|
|
1017
1095
|
return if resolution.nil? || resolution.master?
|
|
1018
1096
|
return if protected_fields.nil? || protected_fields.empty?
|
|
1019
|
-
|
|
1020
|
-
|
|
1097
|
+
return unless ProtectedPaths.touches?(highlight_field, protected_fields)
|
|
1098
|
+
path = highlight_field.is_a?(String) || highlight_field.is_a?(Symbol) ? highlight_field.to_s : highlight_field.inspect
|
|
1021
1099
|
raise Parse::CLPScope::Denied.new(
|
|
1022
1100
|
nil, :find,
|
|
1023
1101
|
"Parse::AtlasSearch.search refused: highlight_field '#{path}' is in " \
|
|
@@ -1026,6 +1104,35 @@ module Parse
|
|
|
1026
1104
|
)
|
|
1027
1105
|
end
|
|
1028
1106
|
|
|
1107
|
+
# Refuse a scoped text search whose paths include a protected field,
|
|
1108
|
+
# or that searches every field (no `fields:`) while the scope has
|
|
1109
|
+
# protected fields. Stripping a protected field from the RESULT does
|
|
1110
|
+
# not stop it from deciding which documents MATCH and how they RANK,
|
|
1111
|
+
# so a caller could test guesses against its value. Master scopes and
|
|
1112
|
+
# scopes with nothing protected are unaffected.
|
|
1113
|
+
#
|
|
1114
|
+
# @raise [Parse::CLPScope::Denied]
|
|
1115
|
+
def assert_search_fields_allowed!(fields, protected_fields, resolution)
|
|
1116
|
+
return if resolution.nil? || resolution.master?
|
|
1117
|
+
return if protected_fields.nil? || protected_fields.empty?
|
|
1118
|
+
if fields.nil? || fields.empty?
|
|
1119
|
+
raise Parse::CLPScope::Denied.new(
|
|
1120
|
+
nil, :find,
|
|
1121
|
+
"Parse::AtlasSearch.search refused: a search over every field would " \
|
|
1122
|
+
"match on protectedFields for the current scope; pass fields: with " \
|
|
1123
|
+
"the fields to search.",
|
|
1124
|
+
)
|
|
1125
|
+
end
|
|
1126
|
+
list = fields.is_a?(Array) ? fields : [fields]
|
|
1127
|
+
hit = list.find { |f| ProtectedPaths.touches?(f, protected_fields) }
|
|
1128
|
+
return unless hit
|
|
1129
|
+
raise Parse::CLPScope::Denied.new(
|
|
1130
|
+
nil, :find,
|
|
1131
|
+
"Parse::AtlasSearch.search refused: field '#{hit}' is in protectedFields " \
|
|
1132
|
+
"for the current scope; matching on it would reveal its value.",
|
|
1133
|
+
)
|
|
1134
|
+
end
|
|
1135
|
+
|
|
1029
1136
|
# Drop `_highlights` entries whose `path` matches a
|
|
1030
1137
|
# protectedFields entry. Defense-in-depth complement to
|
|
1031
1138
|
# {.assert_highlight_field_allowed!} — that gate refuses the
|
|
@@ -1035,13 +1142,14 @@ module Parse
|
|
|
1035
1142
|
def strip_protected_highlights!(documents, protected_fields)
|
|
1036
1143
|
return if documents.nil? || documents.empty?
|
|
1037
1144
|
return if protected_fields.nil? || protected_fields.empty?
|
|
1038
|
-
protected_set = protected_fields.to_set
|
|
1039
1145
|
documents.each do |doc|
|
|
1040
1146
|
next unless doc.is_a?(Hash)
|
|
1041
1147
|
highlights = doc["_highlights"]
|
|
1042
1148
|
next unless highlights.is_a?(Array)
|
|
1043
1149
|
doc["_highlights"] = highlights.reject do |h|
|
|
1044
|
-
|
|
1150
|
+
# A highlight entry with no usable path is dropped too: it
|
|
1151
|
+
# cannot be shown to address an unprotected field.
|
|
1152
|
+
h.is_a?(Hash) && ProtectedPaths.touches?(h["path"] || h[:path], protected_fields)
|
|
1045
1153
|
end
|
|
1046
1154
|
end
|
|
1047
1155
|
end
|
|
@@ -1172,6 +1280,18 @@ module Parse
|
|
|
1172
1280
|
Parse::PipelineSecurity.refuse_protected_field_references!(
|
|
1173
1281
|
[{ "$match" => filter }], collection_name, resolution,
|
|
1174
1282
|
)
|
|
1283
|
+
# Predicate KEYS decide matches too: `{ "ssn" => /^1/ }` is the
|
|
1284
|
+
# same oracle as the `$expr` form above. Refuse any key (top
|
|
1285
|
+
# level or nested under $and/$or/$nor/$not, dotted or `_p_`
|
|
1286
|
+
# storage form) that touches a protected field.
|
|
1287
|
+
unless resolution.master?
|
|
1288
|
+
protected_fields = Parse::CLPScope.protected_fields_for(
|
|
1289
|
+
collection_name, resolution.permission_strings,
|
|
1290
|
+
)
|
|
1291
|
+
ProtectedPaths.assert_filter_allowed!(
|
|
1292
|
+
filter, protected_fields, resolution, collection_name: collection_name,
|
|
1293
|
+
)
|
|
1294
|
+
end
|
|
1175
1295
|
end
|
|
1176
1296
|
|
|
1177
1297
|
filter
|
data/lib/parse/authorization.rb
CHANGED
|
@@ -97,12 +97,68 @@ module Parse
|
|
|
97
97
|
@mutex.synchronize { @data.delete(key) }
|
|
98
98
|
end
|
|
99
99
|
|
|
100
|
+
# Drop every entry whose value equals `value`. Lets the identity plane
|
|
101
|
+
# forget every token of one user after a revocation, since it is keyed
|
|
102
|
+
# by token and holds the user id as the value.
|
|
103
|
+
# @param value [Object]
|
|
104
|
+
# @return [Integer] number of entries removed.
|
|
105
|
+
def invalidate_value(value)
|
|
106
|
+
@mutex.synchronize do
|
|
107
|
+
before = @data.size
|
|
108
|
+
@data.delete_if { |_key, entry| entry[:value] == value }
|
|
109
|
+
before - @data.size
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
100
113
|
# Drop every entry.
|
|
101
114
|
def clear
|
|
102
115
|
@mutex.synchronize { @data.clear }
|
|
103
116
|
end
|
|
104
117
|
end
|
|
105
118
|
|
|
119
|
+
# Process-local record of session objectId to owning user id, kept apart
|
|
120
|
+
# from the identity plane so no session token can ever read a record.
|
|
121
|
+
# Entries expire after `ttl` and the map holds at most {MAX_ENTRIES},
|
|
122
|
+
# dropping the oldest first. Values are stored typed
|
|
123
|
+
# (`{"session_owner" => user_id}`) so a shared store passed in its place
|
|
124
|
+
# cannot confuse a record with an identity entry either.
|
|
125
|
+
class SessionOwnerMap
|
|
126
|
+
MAX_ENTRIES = 10_000
|
|
127
|
+
|
|
128
|
+
def initialize
|
|
129
|
+
@data = {}
|
|
130
|
+
@mutex = Mutex.new
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def get(key)
|
|
134
|
+
@mutex.synchronize do
|
|
135
|
+
entry = @data[key]
|
|
136
|
+
return nil if entry.nil?
|
|
137
|
+
if entry[:expires_at] < Time.now
|
|
138
|
+
@data.delete(key)
|
|
139
|
+
return nil
|
|
140
|
+
end
|
|
141
|
+
entry[:value]
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def set(key, value, ttl:)
|
|
146
|
+
@mutex.synchronize do
|
|
147
|
+
@data.delete(key)
|
|
148
|
+
@data[key] = { value: value, expires_at: Time.now + ttl }
|
|
149
|
+
@data.shift while @data.size > MAX_ENTRIES
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def invalidate(key)
|
|
154
|
+
@mutex.synchronize { @data.delete(key) }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def clear
|
|
158
|
+
@mutex.synchronize { @data.clear }
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
106
162
|
# The outcome of resolving a caller. `user_id` is the `_User.objectId`
|
|
107
163
|
# owning the session, or `nil` for an anonymous caller. `role_names` is a
|
|
108
164
|
# `Set` of bare role names (no `role:` prefix) the user inherits
|
|
@@ -176,8 +232,26 @@ module Parse
|
|
|
176
232
|
@role_cache_ttl = DEFAULT_ROLE_TTL
|
|
177
233
|
@upstream_role_reader = nil
|
|
178
234
|
@compare_upstream_roles = false
|
|
235
|
+
# Bumped by every invalidation. A token resolution captures it before
|
|
236
|
+
# calling `/users/me` and does not cache its answer when it moved, so
|
|
237
|
+
# a resolve already in flight when a session is revoked cannot put
|
|
238
|
+
# the revoked token back into the plane. A generation-capable plane
|
|
239
|
+
# (the shared Redis identity plane) keeps a plane-wide counter too,
|
|
240
|
+
# so the same holds across processes. See {#lookup_user_id}.
|
|
241
|
+
@invalidation_epoch = 0
|
|
242
|
+
@epoch_mutex = Mutex.new
|
|
243
|
+
@session_owner_cache = SessionOwnerMap.new
|
|
179
244
|
end
|
|
180
245
|
|
|
246
|
+
# Where {#remember_session_owner} records which user owns a session
|
|
247
|
+
# objectId. A separate store from the identity plane, so a session
|
|
248
|
+
# token can never read a record. Defaults to a process-local
|
|
249
|
+
# {SessionOwnerMap}; set a shared store (anything with `get`, `set`
|
|
250
|
+
# with `ttl:`, `invalidate`, and `clear`) to share records across
|
|
251
|
+
# processes.
|
|
252
|
+
# @return [Object]
|
|
253
|
+
attr_accessor :session_owner_cache
|
|
254
|
+
|
|
181
255
|
# Apply settings, leaving anything not passed unchanged.
|
|
182
256
|
# @return [self]
|
|
183
257
|
def configure(identity_cache: nil, role_cache: nil,
|
|
@@ -233,9 +307,40 @@ module Parse
|
|
|
233
307
|
# @param session_token [String]
|
|
234
308
|
def invalidate(session_token)
|
|
235
309
|
return if session_token.nil?
|
|
310
|
+
bump_invalidation_epoch!
|
|
236
311
|
@identity_cache.invalidate(session_token.to_s)
|
|
237
312
|
end
|
|
238
313
|
|
|
314
|
+
# Forget every cached identity entry that resolves to `user_id`. Call
|
|
315
|
+
# after an event that revokes the user's sessions: a password change,
|
|
316
|
+
# account deletion, a session destroy, or "log out everywhere". The SDK
|
|
317
|
+
# calls it itself on those paths.
|
|
318
|
+
#
|
|
319
|
+
# The identity plane is keyed by token, so there is no direct way to
|
|
320
|
+
# name a user's entries. A generation-capable plane (the keyspaced
|
|
321
|
+
# Redis identity plane) bumps the user's generation, which rejects
|
|
322
|
+
# every entry for that user, including tokens this process never
|
|
323
|
+
# resolved. The default {MemoryCache} drops the matching values. A
|
|
324
|
+
# custom plane that supports neither is left to its TTL.
|
|
325
|
+
#
|
|
326
|
+
# The role entry is dropped too: it is cheap to rebuild and a deleted
|
|
327
|
+
# user should not keep a role closure around.
|
|
328
|
+
# @param user_id [String]
|
|
329
|
+
# @return [void]
|
|
330
|
+
def invalidate_user(user_id)
|
|
331
|
+
return if user_id.nil? || user_id.to_s.empty?
|
|
332
|
+
uid = user_id.to_s
|
|
333
|
+
bump_invalidation_epoch!
|
|
334
|
+
cache = @identity_cache
|
|
335
|
+
if generation_capable?(cache) && cache.respond_to?(:bump_generation)
|
|
336
|
+
cache.bump_generation(uid)
|
|
337
|
+
elsif cache.respond_to?(:invalidate_value)
|
|
338
|
+
cache.invalidate_value(uid)
|
|
339
|
+
end
|
|
340
|
+
@role_cache.invalidate(uid)
|
|
341
|
+
nil
|
|
342
|
+
end
|
|
343
|
+
|
|
239
344
|
# Forget one user's cached role closure. Call after any `_Role.users`
|
|
240
345
|
# mutation affecting them.
|
|
241
346
|
# @param user_id [String]
|
|
@@ -244,10 +349,50 @@ module Parse
|
|
|
244
349
|
@role_cache.invalidate(user_id.to_s)
|
|
245
350
|
end
|
|
246
351
|
|
|
352
|
+
# Forget every cached role closure. Call after a `_Role.roles` hierarchy
|
|
353
|
+
# change, which can affect any user holding a role in that hierarchy.
|
|
354
|
+
def invalidate_all_roles
|
|
355
|
+
@role_cache.clear if @role_cache.respond_to?(:clear)
|
|
356
|
+
end
|
|
357
|
+
|
|
247
358
|
# Drop every entry in both planes.
|
|
248
359
|
def reset_caches!
|
|
360
|
+
bump_invalidation_epoch!
|
|
249
361
|
@identity_cache.clear if @identity_cache.respond_to?(:clear)
|
|
250
362
|
@role_cache.clear if @role_cache.respond_to?(:clear)
|
|
363
|
+
# A plane clear takes the plane-wide marker with it. Bump it again so
|
|
364
|
+
# it does not sit at a value a lookup that started before the reset
|
|
365
|
+
# could have captured.
|
|
366
|
+
bump_plane_epoch!
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
# Record which user owns a session objectId, so a later delete of that
|
|
370
|
+
# session can drop the owner's cached identities even when the delete
|
|
371
|
+
# can no longer read the session row (it is already gone, or not
|
|
372
|
+
# visible to the caller). Only the owner's user id is stored, never the
|
|
373
|
+
# token. Called when a `_Session` row with both is loaded from the
|
|
374
|
+
# server. The entry lives as long as an identity entry.
|
|
375
|
+
# @param session_id [String]
|
|
376
|
+
# @param user_id [String]
|
|
377
|
+
# @return [void]
|
|
378
|
+
def remember_session_owner(session_id, user_id)
|
|
379
|
+
return if session_id.to_s.empty? || user_id.to_s.empty?
|
|
380
|
+
@session_owner_cache.set(session_id.to_s, { "session_owner" => user_id.to_s }, ttl: @identity_cache_ttl)
|
|
381
|
+
nil
|
|
382
|
+
rescue StandardError
|
|
383
|
+
nil
|
|
384
|
+
end
|
|
385
|
+
|
|
386
|
+
# The owner recorded by {#remember_session_owner}, or nil.
|
|
387
|
+
# @param session_id [String]
|
|
388
|
+
# @return [String, nil]
|
|
389
|
+
def session_owner(session_id)
|
|
390
|
+
return nil if session_id.to_s.empty?
|
|
391
|
+
value = @session_owner_cache.get(session_id.to_s)
|
|
392
|
+
owner = value.is_a?(Hash) ? value["session_owner"] : nil
|
|
393
|
+
owner.is_a?(String) && !owner.empty? ? owner : nil
|
|
394
|
+
rescue StandardError
|
|
395
|
+
nil
|
|
251
396
|
end
|
|
252
397
|
|
|
253
398
|
def inspect
|
|
@@ -266,8 +411,21 @@ module Parse
|
|
|
266
411
|
cached = cached_user_id(session_token)
|
|
267
412
|
return cached unless cached.nil?
|
|
268
413
|
|
|
414
|
+
# Captured before `/users/me`: an invalidation that lands while the
|
|
415
|
+
# lookup is in flight must win over the answer it returns. The plane
|
|
416
|
+
# marker is created first if a clear removed it, so the snapshot
|
|
417
|
+
# never holds "no marker", which a concurrent reset also produces.
|
|
418
|
+
ensure_plane_marker!
|
|
419
|
+
snapshot = invalidation_snapshot
|
|
420
|
+
prior_uid, prior_gen = prior_generation(session_token)
|
|
421
|
+
|
|
269
422
|
response = begin
|
|
270
|
-
|
|
423
|
+
# cache: false: a revoked or expired token must not re-resolve
|
|
424
|
+
# from a cached /users/me response after its identity entry is
|
|
425
|
+
# evicted or invalidated. The identity plane above is the only
|
|
426
|
+
# cache on this path, so its TTL and invalidation hooks bound
|
|
427
|
+
# revocation.
|
|
428
|
+
@client.current_user(session_token, cache: false)
|
|
271
429
|
rescue => e
|
|
272
430
|
raise InvalidSession, "session token lookup failed: #{e.class}: #{e.message}"
|
|
273
431
|
end
|
|
@@ -278,10 +436,136 @@ module Parse
|
|
|
278
436
|
raise InvalidSession, "session token resolved no user objectId" if user_id.nil? || user_id.to_s.empty?
|
|
279
437
|
|
|
280
438
|
user_id = user_id.to_s
|
|
281
|
-
|
|
439
|
+
# A stale entry for the same user tells us its generation from
|
|
440
|
+
# before the lookup; storing under it lets a bump made across
|
|
441
|
+
# processes during the lookup still reject the entry.
|
|
442
|
+
store_unless_invalidated(session_token, user_id, snapshot,
|
|
443
|
+
gen: prior_uid == user_id ? prior_gen : nil)
|
|
282
444
|
user_id
|
|
283
445
|
end
|
|
284
446
|
|
|
447
|
+
# Cache a resolved identity only if no invalidation happened since
|
|
448
|
+
# `snapshot` was taken, without a window between the check and the
|
|
449
|
+
# write.
|
|
450
|
+
#
|
|
451
|
+
# Every invalidation bumps the counters BEFORE it drops entries. The
|
|
452
|
+
# write is checked before and re-checked after; a change seen by the
|
|
453
|
+
# second check evicts what was just written. So an invalidation either
|
|
454
|
+
# bumped before the re-check (the entry is evicted here) or bumped
|
|
455
|
+
# after it, which means it also drops entries after the write (the
|
|
456
|
+
# entry is removed, or its generation goes stale, by the invalidation
|
|
457
|
+
# itself). Either way the revoked identity does not stay cached. The
|
|
458
|
+
# plane-wide counter extends this to invalidations made by other
|
|
459
|
+
# processes sharing a generation-capable plane.
|
|
460
|
+
def store_unless_invalidated(session_token, user_id, snapshot, gen: nil)
|
|
461
|
+
return unless snapshot_usable?(snapshot)
|
|
462
|
+
return unless invalidation_snapshot == snapshot
|
|
463
|
+
store_user_id(session_token, user_id, gen: gen)
|
|
464
|
+
return if invalidation_snapshot == snapshot
|
|
465
|
+
@identity_cache.invalidate(session_token)
|
|
466
|
+
rescue StandardError
|
|
467
|
+
# Not caching is always safe: the next read resolves again.
|
|
468
|
+
begin
|
|
469
|
+
@identity_cache.invalidate(session_token)
|
|
470
|
+
rescue StandardError
|
|
471
|
+
nil
|
|
472
|
+
end
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# The process counter plus, on a generation-capable plane, the
|
|
476
|
+
# plane-wide counter shared by every process using that plane.
|
|
477
|
+
def invalidation_snapshot
|
|
478
|
+
[current_invalidation_epoch, plane_epoch]
|
|
479
|
+
end
|
|
480
|
+
|
|
481
|
+
# A snapshot whose plane counter could not be read cannot prove that no
|
|
482
|
+
# other process invalidated during the lookup, so it never caches.
|
|
483
|
+
#
|
|
484
|
+
# An absent marker on a plane that keeps one is not usable either: a
|
|
485
|
+
# reset in another process deletes the marker before it installs a new
|
|
486
|
+
# one, so "absent" before and after the write cannot prove that no reset
|
|
487
|
+
# ran in between. The re-check after the write compares against a
|
|
488
|
+
# present marker, so reading it absent there evicts the write.
|
|
489
|
+
def snapshot_usable?(snapshot)
|
|
490
|
+
snapshot[1] != :unavailable && snapshot[1] != :absent
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Create the plane-wide marker when the plane keeps one and it is
|
|
494
|
+
# missing (a fresh plane, or one a reset has just cleared).
|
|
495
|
+
def ensure_plane_marker!
|
|
496
|
+
cache = @identity_cache
|
|
497
|
+
if cache.respond_to?(:invalidation_nonce)
|
|
498
|
+
cache.bump_invalidation_nonce if cache.invalidation_nonce.nil?
|
|
499
|
+
elsif generation_capable?(cache) && cache.respond_to?(:bump_generation)
|
|
500
|
+
gen = cache.generation(PLANE_EPOCH_SUBJECT)
|
|
501
|
+
cache.bump_generation(PLANE_EPOCH_SUBJECT) if gen.nil? || gen.to_i.zero?
|
|
502
|
+
end
|
|
503
|
+
rescue StandardError
|
|
504
|
+
nil
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
def bump_invalidation_epoch!
|
|
508
|
+
@epoch_mutex.synchronize { @invalidation_epoch += 1 }
|
|
509
|
+
bump_plane_epoch!
|
|
510
|
+
end
|
|
511
|
+
|
|
512
|
+
def current_invalidation_epoch
|
|
513
|
+
@epoch_mutex.synchronize { @invalidation_epoch }
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
# Reserved generation subject for the plane-wide invalidation counter
|
|
517
|
+
# on a custom generation-capable plane without an invalidation nonce.
|
|
518
|
+
# Parse objectIds are alphanumeric, so it cannot name a real user.
|
|
519
|
+
PLANE_EPOCH_SUBJECT = "~identity-invalidations"
|
|
520
|
+
|
|
521
|
+
# The plane-wide invalidation marker: a random nonce replaced on every
|
|
522
|
+
# invalidation when the plane offers one ({Parse::Cache::SubCache}
|
|
523
|
+
# does), else a generation counter. nil when the plane has neither,
|
|
524
|
+
# `:absent` when the plane keeps one but it is missing (fresh or just
|
|
525
|
+
# cleared), `:unavailable` when reading it failed. A nonce never repeats,
|
|
526
|
+
# so a plane clear that drops it cannot make an old value come back. A
|
|
527
|
+
# generation counter can: a custom plane whose `clear` also deletes its
|
|
528
|
+
# generations restarts the counter, so a lookup racing another
|
|
529
|
+
# process's reset is bounded by `identity_cache_ttl`. Such a plane
|
|
530
|
+
# should implement `invalidation_nonce` / `bump_invalidation_nonce`.
|
|
531
|
+
def plane_epoch
|
|
532
|
+
cache = @identity_cache
|
|
533
|
+
if cache.respond_to?(:invalidation_nonce)
|
|
534
|
+
nonce = cache.invalidation_nonce
|
|
535
|
+
return nonce.nil? ? :absent : nonce
|
|
536
|
+
end
|
|
537
|
+
return nil unless generation_capable?(cache)
|
|
538
|
+
gen = cache.generation(PLANE_EPOCH_SUBJECT)
|
|
539
|
+
gen.nil? || gen.to_i.zero? ? :absent : gen
|
|
540
|
+
rescue StandardError
|
|
541
|
+
:unavailable
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def bump_plane_epoch!
|
|
545
|
+
cache = @identity_cache
|
|
546
|
+
if cache.respond_to?(:bump_invalidation_nonce)
|
|
547
|
+
cache.bump_invalidation_nonce
|
|
548
|
+
elsif generation_capable?(cache) && cache.respond_to?(:bump_generation)
|
|
549
|
+
cache.bump_generation(PLANE_EPOCH_SUBJECT)
|
|
550
|
+
end
|
|
551
|
+
rescue StandardError
|
|
552
|
+
nil
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
# The user id and that user's current generation from a stale entry
|
|
556
|
+
# already stored for the token, read before `/users/me`.
|
|
557
|
+
# @return [Array(String, Object), Array(nil, nil)]
|
|
558
|
+
def prior_generation(session_token)
|
|
559
|
+
cache = @identity_cache
|
|
560
|
+
return [nil, nil] unless generation_capable?(cache)
|
|
561
|
+
raw = cache.get(session_token)
|
|
562
|
+
uid = raw.is_a?(Hash) ? (raw["user_id"] || raw[:user_id]) : nil
|
|
563
|
+
return [nil, nil] if uid.nil?
|
|
564
|
+
[uid.to_s, cache.generation(uid.to_s)]
|
|
565
|
+
rescue StandardError
|
|
566
|
+
[nil, nil]
|
|
567
|
+
end
|
|
568
|
+
|
|
285
569
|
# Read the identity plane and, where the plane supports it, check that
|
|
286
570
|
# the entry's generation is still current.
|
|
287
571
|
#
|
|
@@ -316,10 +600,11 @@ module Parse
|
|
|
316
600
|
|
|
317
601
|
# Write the identity entry, tagging it with the subject's current
|
|
318
602
|
# generation when the plane can track one.
|
|
319
|
-
def store_user_id(session_token, user_id)
|
|
603
|
+
def store_user_id(session_token, user_id, gen: nil)
|
|
320
604
|
cache = @identity_cache
|
|
321
605
|
if generation_capable?(cache)
|
|
322
|
-
|
|
606
|
+
gen = cache.generation(user_id) if gen.nil?
|
|
607
|
+
cache.set(session_token, { "user_id" => user_id, "gen" => gen },
|
|
323
608
|
ttl: @identity_cache_ttl)
|
|
324
609
|
else
|
|
325
610
|
cache.set(session_token, user_id, ttl: @identity_cache_ttl)
|