parse-stack-next 5.8.0 → 5.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +630 -0
- data/README.md +31 -19
- data/docs/caching.md +90 -5
- data/docs/mcp_guide.md +22 -5
- data/docs/mongodb_direct_guide.md +29 -0
- data/docs/webhooks_guide.md +94 -9
- data/lib/parse/acl_scope.rb +71 -9
- data/lib/parse/agent/constraint_translator.rb +22 -21
- data/lib/parse/agent/describe.rb +8 -2
- data/lib/parse/agent/errors.rb +17 -0
- data/lib/parse/agent/mcp_dispatcher.rb +8 -1
- data/lib/parse/agent/mcp_server.rb +47 -6
- data/lib/parse/agent.rb +521 -70
- data/lib/parse/api/batch.rb +70 -2
- data/lib/parse/atlas_search/index_manager.rb +5 -1
- data/lib/parse/atlas_search.rb +50 -1
- data/lib/parse/authorization.rb +235 -3
- data/lib/parse/cache/sub_cache.rb +26 -0
- data/lib/parse/client/authentication.rb +19 -1
- data/lib/parse/client/batch.rb +263 -11
- data/lib/parse/client/body_builder.rb +100 -15
- data/lib/parse/client/caching.rb +53 -3
- data/lib/parse/client/request.rb +59 -0
- data/lib/parse/client/response.rb +63 -5
- data/lib/parse/client.rb +173 -33
- data/lib/parse/clp_scope.rb +37 -3
- data/lib/parse/live_query/client.rb +142 -11
- data/lib/parse/lock_backend.rb +4 -3
- data/lib/parse/model/associations/collection_proxy.rb +19 -11
- data/lib/parse/model/associations/has_many.rb +11 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +36 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +97 -12
- data/lib/parse/model/classes/role.rb +23 -0
- data/lib/parse/model/classes/session.rb +307 -19
- data/lib/parse/model/classes/user.rb +33 -12
- data/lib/parse/model/core/actions.rb +41 -5
- data/lib/parse/model/core/fetching.rb +29 -2
- data/lib/parse/model/core/field_guards.rb +16 -8
- data/lib/parse/model/file.rb +96 -19
- data/lib/parse/model/object.rb +52 -1
- data/lib/parse/model/push.rb +11 -1
- data/lib/parse/mongodb.rb +43 -2
- data/lib/parse/pipeline_security.rb +157 -21
- data/lib/parse/query/constraint.rb +1 -1
- data/lib/parse/query/constraints.rb +665 -41
- data/lib/parse/query.rb +802 -60
- data/lib/parse/stack/tasks.rb +1 -1
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +23 -0
- data/lib/parse/vector_search/hybrid.rb +78 -16
- data/lib/parse/vector_search.rb +215 -7
- data/lib/parse/webhooks/payload.rb +82 -8
- data/lib/parse/webhooks/registration.rb +26 -6
- data/lib/parse/webhooks/replay_protection.rb +19 -9
- data/lib/parse/webhooks.rb +329 -13
- metadata +1 -1
data/lib/parse/api/batch.rb
CHANGED
|
@@ -19,13 +19,81 @@ module Parse
|
|
|
19
19
|
# @overload batch_request(operation)
|
|
20
20
|
# Submit a batch operation.
|
|
21
21
|
# @param operation [Parse::BatchOperation] the batch operation.
|
|
22
|
+
# @param opts [Hash] request options for the `POST /batch` call itself,
|
|
23
|
+
# such as `session_token:` or `use_master_key:`. Parse Server runs
|
|
24
|
+
# every sub-request under the credentials of this one call (it ignores
|
|
25
|
+
# per-sub-request headers), so the batch's authority is set here. A
|
|
26
|
+
# request that names no credentials of its own runs under these.
|
|
27
|
+
# Narrowing options always win: `use_master_key: false` here keeps
|
|
28
|
+
# the master key off even for a request that set
|
|
29
|
+
# `use_master_key: true`. An option here never widens a request.
|
|
30
|
+
# @note Every request is sent through this client, as
|
|
31
|
+
# {Parse::Client#request} sends a single request through the client
|
|
32
|
+
# it is called on, even when the request was built for another
|
|
33
|
+
# object's class client.
|
|
34
|
+
# @note Each {Parse::Request}'s own credentials (its `session_token:` /
|
|
35
|
+
# `use_master_key:` options and its session-token or master-key
|
|
36
|
+
# suppression headers) are honored.
|
|
37
|
+
# When every request resolves to this client and one set of
|
|
38
|
+
# credentials, the batch is one call. A transaction that would mix
|
|
39
|
+
# credentials raises {Parse::BatchOperation::MixedAuthorityError}
|
|
40
|
+
# before anything is sent. A non-transactional batch with mixed
|
|
41
|
+
# credentials is sent as one call per set of credentials, through
|
|
42
|
+
# {Parse::BatchOperation#submit}, with responses in request order.
|
|
43
|
+
# Elements that are not {Parse::Request} instances are ignored, as
|
|
44
|
+
# {Parse::BatchOperation#add} ignores them.
|
|
22
45
|
# @return [Array<Parse::Response>] if successful, a set of responses for each operation in the batch.
|
|
23
46
|
# @return [Parse::Response] if an error occurred, the error response.
|
|
24
|
-
|
|
47
|
+
# @raise [Parse::BatchOperation::MixedAuthorityError] for a transaction
|
|
48
|
+
# whose requests name different credentials.
|
|
49
|
+
def batch_request(batch_operations, **opts)
|
|
25
50
|
unless batch_operations.is_a?(Parse::BatchOperation)
|
|
26
51
|
batch_operations = Parse::BatchOperation.new batch_operations
|
|
27
52
|
end
|
|
28
|
-
|
|
53
|
+
call_auth = opts.slice(:session_token, :use_master_key, :suppress_master_key)
|
|
54
|
+
groups = batch_operations.send(:authority_groups, self, call_auth, force_client: true)
|
|
55
|
+
if groups.empty?
|
|
56
|
+
return post_batch_operation(batch_operations, opts.except(:suppress_master_key))
|
|
57
|
+
end
|
|
58
|
+
if groups.size == 1 &&
|
|
59
|
+
Parse::BatchOperation.credential_fingerprint(groups.first[:client]) ==
|
|
60
|
+
Parse::BatchOperation.credential_fingerprint(self)
|
|
61
|
+
send_opts = opts.except(:session_token, :use_master_key, :suppress_master_key)
|
|
62
|
+
.merge(groups.first[:opts])
|
|
63
|
+
return post_batch_operation(batch_operations, send_opts)
|
|
64
|
+
end
|
|
65
|
+
if batch_operations.transaction
|
|
66
|
+
raise Parse::BatchOperation::MixedAuthorityError,
|
|
67
|
+
"A transaction cannot mix requests built for different credentials or " \
|
|
68
|
+
"another client. Parse Server runs a batch under one credential, so it " \
|
|
69
|
+
"cannot honor each request's own. Save these objects in separate transactions."
|
|
70
|
+
end
|
|
71
|
+
# Mixed credentials, not a transaction: send one call per set of
|
|
72
|
+
# credentials, keeping responses in request order.
|
|
73
|
+
# Assigned, not re-added: `add` would drop tagged duplicates and leave
|
|
74
|
+
# fewer responses than requests.
|
|
75
|
+
routed = Parse::BatchOperation.new
|
|
76
|
+
routed.requests = batch_operations.requests.dup
|
|
77
|
+
routed.client = self
|
|
78
|
+
routed.batch_defaults = call_auth
|
|
79
|
+
routed.submit
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
# Send one `POST /batch` call and map its result to responses.
|
|
85
|
+
# @param batch_operations [Parse::BatchOperation]
|
|
86
|
+
# @param opts [Hash] request options for the call.
|
|
87
|
+
def post_batch_operation(batch_operations, opts)
|
|
88
|
+
opts = opts.dup
|
|
89
|
+
headers = nil
|
|
90
|
+
# Sent as the header itself, exactly as a single request carries it:
|
|
91
|
+
# the authentication middleware drops the master key when it is set,
|
|
92
|
+
# even with `use_master_key: true`.
|
|
93
|
+
if opts.delete(:suppress_master_key)
|
|
94
|
+
headers = { Parse::Middleware::Authentication::DISABLE_MASTER_KEY => "true" }
|
|
95
|
+
end
|
|
96
|
+
response = request(:post, "batch", body: batch_operations.as_json, headers: headers, opts: opts)
|
|
29
97
|
return response.batch_responses if response.success? && response.batch?
|
|
30
98
|
return response if response.error?
|
|
31
99
|
# A successful HTTP response whose body is not an array of results
|
|
@@ -65,7 +65,11 @@ module Parse
|
|
|
65
65
|
# `master: true` so the SDK's CLP layer skips this metadata
|
|
66
66
|
# pipeline. The mongo-side privilege check still applies
|
|
67
67
|
# (the underlying connection must hold `listSearchIndexes`).
|
|
68
|
-
|
|
68
|
+
# The metadata sentinel keeps that master mode inside a
|
|
69
|
+
# `Parse.without_master_key` block, which governs row access,
|
|
70
|
+
# without re-enabling the master key for anything else.
|
|
71
|
+
results = Parse::MongoDB.aggregate(collection_name, pipeline,
|
|
72
|
+
master: Parse::ACLScope::METADATA_MASTER)
|
|
69
73
|
cache_mutex.synchronize { cache_indexes(collection_name, results) }
|
|
70
74
|
results
|
|
71
75
|
rescue => e
|
data/lib/parse/atlas_search.rb
CHANGED
|
@@ -559,6 +559,7 @@ module Parse
|
|
|
559
559
|
unless resolution.master?
|
|
560
560
|
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
561
561
|
pipeline << acl_match if acl_match
|
|
562
|
+
pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
|
|
562
563
|
end
|
|
563
564
|
|
|
564
565
|
# Add filter if provided
|
|
@@ -660,6 +661,17 @@ module Parse
|
|
|
660
661
|
"accept that bucket counts include all rows, or use " \
|
|
661
662
|
"#search for ACL-scoped results without facets."
|
|
662
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
|
|
663
675
|
# Wave-3b READPREF-4: see #search for rationale. Captured
|
|
664
676
|
# before resolve_scope! pops the auth kwargs so the recursive
|
|
665
677
|
# search() call below can re-thread it explicitly (resolve!
|
|
@@ -815,6 +827,7 @@ module Parse
|
|
|
815
827
|
unless resolution.master?
|
|
816
828
|
acl_match = Parse::ACLScope.match_stage_for(resolution)
|
|
817
829
|
pipeline << acl_match if acl_match
|
|
830
|
+
pipeline << pointer_fields_match_stage(pointer_fields, resolution) if pointer_fields
|
|
818
831
|
end
|
|
819
832
|
|
|
820
833
|
# Caller-supplied filter, sanitized against operator injection
|
|
@@ -851,6 +864,8 @@ module Parse
|
|
|
851
864
|
unless resolution.master?
|
|
852
865
|
Parse::ACLScope.redact_results!(raw_results, resolution)
|
|
853
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.
|
|
854
869
|
if pointer_fields
|
|
855
870
|
raw_results = Parse::CLPScope.filter_by_pointer_fields(
|
|
856
871
|
raw_results, pointer_fields, resolution.user_id,
|
|
@@ -972,13 +987,37 @@ module Parse
|
|
|
972
987
|
return Parse::ACLScope.resolve_for_role(acl_role, client: auth_client)
|
|
973
988
|
end
|
|
974
989
|
|
|
975
|
-
if master == true
|
|
990
|
+
if master == true && !Parse::ACLScope.master_key_suppressed?
|
|
976
991
|
return Parse::ACLScope::Resolution.new(
|
|
977
992
|
mode: :master, permission_strings: nil, user_id: nil, session: nil,
|
|
978
993
|
client: auth_client,
|
|
979
994
|
)
|
|
980
995
|
end
|
|
981
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
|
+
|
|
982
1021
|
if @require_session_token == true
|
|
983
1022
|
raise ACLRequired,
|
|
984
1023
|
"Parse::AtlasSearch.#{method_name} requires session_token: or " \
|
|
@@ -1037,6 +1076,15 @@ module Parse
|
|
|
1037
1076
|
label: "Atlas Search")
|
|
1038
1077
|
end
|
|
1039
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) }
|
|
1086
|
+
end
|
|
1087
|
+
|
|
1040
1088
|
# ATLAS-4: refuse `highlight_field:` when the field is in the
|
|
1041
1089
|
# resolved protectedFields set. searchHighlights returns the
|
|
1042
1090
|
# matched token plus surrounding chars verbatim; running it on
|
|
@@ -1120,6 +1168,7 @@ module Parse
|
|
|
1120
1168
|
def run_atlas_pipeline!(collection_name, pipeline, max_time_ms = nil, read_preference: nil,
|
|
1121
1169
|
authorizing_client: nil)
|
|
1122
1170
|
agg_opts = {}
|
|
1171
|
+
max_time_ms ||= Parse::PipelineSecurity.regex_time_budget(pipeline)
|
|
1123
1172
|
agg_opts[:max_time_ms] = max_time_ms if max_time_ms
|
|
1124
1173
|
# Atlas Search does not go through Parse::MongoDB.aggregate, so this
|
|
1125
1174
|
# is the only place its reads meet the binding guard.
|
data/lib/parse/authorization.rb
CHANGED
|
@@ -116,6 +116,49 @@ module Parse
|
|
|
116
116
|
end
|
|
117
117
|
end
|
|
118
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
|
+
|
|
119
162
|
# The outcome of resolving a caller. `user_id` is the `_User.objectId`
|
|
120
163
|
# owning the session, or `nil` for an anonymous caller. `role_names` is a
|
|
121
164
|
# `Set` of bare role names (no `role:` prefix) the user inherits
|
|
@@ -189,8 +232,26 @@ module Parse
|
|
|
189
232
|
@role_cache_ttl = DEFAULT_ROLE_TTL
|
|
190
233
|
@upstream_role_reader = nil
|
|
191
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
|
|
192
244
|
end
|
|
193
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
|
+
|
|
194
255
|
# Apply settings, leaving anything not passed unchanged.
|
|
195
256
|
# @return [self]
|
|
196
257
|
def configure(identity_cache: nil, role_cache: nil,
|
|
@@ -246,6 +307,7 @@ module Parse
|
|
|
246
307
|
# @param session_token [String]
|
|
247
308
|
def invalidate(session_token)
|
|
248
309
|
return if session_token.nil?
|
|
310
|
+
bump_invalidation_epoch!
|
|
249
311
|
@identity_cache.invalidate(session_token.to_s)
|
|
250
312
|
end
|
|
251
313
|
|
|
@@ -268,6 +330,7 @@ module Parse
|
|
|
268
330
|
def invalidate_user(user_id)
|
|
269
331
|
return if user_id.nil? || user_id.to_s.empty?
|
|
270
332
|
uid = user_id.to_s
|
|
333
|
+
bump_invalidation_epoch!
|
|
271
334
|
cache = @identity_cache
|
|
272
335
|
if generation_capable?(cache) && cache.respond_to?(:bump_generation)
|
|
273
336
|
cache.bump_generation(uid)
|
|
@@ -294,8 +357,42 @@ module Parse
|
|
|
294
357
|
|
|
295
358
|
# Drop every entry in both planes.
|
|
296
359
|
def reset_caches!
|
|
360
|
+
bump_invalidation_epoch!
|
|
297
361
|
@identity_cache.clear if @identity_cache.respond_to?(:clear)
|
|
298
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
|
|
299
396
|
end
|
|
300
397
|
|
|
301
398
|
def inspect
|
|
@@ -314,6 +411,14 @@ module Parse
|
|
|
314
411
|
cached = cached_user_id(session_token)
|
|
315
412
|
return cached unless cached.nil?
|
|
316
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
|
+
|
|
317
422
|
response = begin
|
|
318
423
|
# cache: false: a revoked or expired token must not re-resolve
|
|
319
424
|
# from a cached /users/me response after its identity entry is
|
|
@@ -331,10 +436,136 @@ module Parse
|
|
|
331
436
|
raise InvalidSession, "session token resolved no user objectId" if user_id.nil? || user_id.to_s.empty?
|
|
332
437
|
|
|
333
438
|
user_id = user_id.to_s
|
|
334
|
-
|
|
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)
|
|
335
444
|
user_id
|
|
336
445
|
end
|
|
337
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
|
+
|
|
338
569
|
# Read the identity plane and, where the plane supports it, check that
|
|
339
570
|
# the entry's generation is still current.
|
|
340
571
|
#
|
|
@@ -369,10 +600,11 @@ module Parse
|
|
|
369
600
|
|
|
370
601
|
# Write the identity entry, tagging it with the subject's current
|
|
371
602
|
# generation when the plane can track one.
|
|
372
|
-
def store_user_id(session_token, user_id)
|
|
603
|
+
def store_user_id(session_token, user_id, gen: nil)
|
|
373
604
|
cache = @identity_cache
|
|
374
605
|
if generation_capable?(cache)
|
|
375
|
-
|
|
606
|
+
gen = cache.generation(user_id) if gen.nil?
|
|
607
|
+
cache.set(session_token, { "user_id" => user_id, "gen" => gen },
|
|
376
608
|
ttl: @identity_cache_ttl)
|
|
377
609
|
else
|
|
378
610
|
cache.set(session_token, user_id, ttl: @identity_cache_ttl)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "digest"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
require "set"
|
|
6
7
|
|
|
7
8
|
module Parse
|
|
@@ -163,6 +164,25 @@ module Parse
|
|
|
163
164
|
value
|
|
164
165
|
end
|
|
165
166
|
|
|
167
|
+
# A random marker replaced on every identity invalidation, read by
|
|
168
|
+
# `Parse::Authorization` before and after it caches a token so an
|
|
169
|
+
# invalidation from any process in between is detected. A nonce rather
|
|
170
|
+
# than a counter: a plane clear drops it, and a counter restarting at
|
|
171
|
+
# 0 could repeat a value an in-flight lookup captured.
|
|
172
|
+
# @return [String, nil] nil when never set or cleared.
|
|
173
|
+
def invalidation_nonce
|
|
174
|
+
value = @store[invalidation_nonce_key]
|
|
175
|
+
value.nil? ? nil : value.to_s
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Replace the invalidation nonce.
|
|
179
|
+
# @return [String] the new nonce.
|
|
180
|
+
def bump_invalidation_nonce
|
|
181
|
+
nonce = SecureRandom.hex(16)
|
|
182
|
+
@store.store(invalidation_nonce_key, nonce, {})
|
|
183
|
+
nonce
|
|
184
|
+
end
|
|
185
|
+
|
|
166
186
|
# @return [Float] the last invalidation epoch, 0.0 when never set.
|
|
167
187
|
def epoch
|
|
168
188
|
@store[epoch_key].to_f
|
|
@@ -253,6 +273,12 @@ module Parse
|
|
|
253
273
|
@keyspace.key(@family, "meta", "epoch")
|
|
254
274
|
end
|
|
255
275
|
|
|
276
|
+
# Sits beside the epoch under the reserved `meta` segment; a token key
|
|
277
|
+
# is a digest, so no token can name it.
|
|
278
|
+
def invalidation_nonce_key
|
|
279
|
+
@keyspace.key(@family, "meta", "invalidation")
|
|
280
|
+
end
|
|
281
|
+
|
|
256
282
|
# Generations live in the same family so a plane clear takes them with it,
|
|
257
283
|
# and are namespaced under `gen:` so they cannot collide with an entry
|
|
258
284
|
# whose logical key happens to be a user id.
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "faraday"
|
|
5
|
+
require "securerandom"
|
|
5
6
|
require "active_support"
|
|
6
7
|
require "active_support/core_ext"
|
|
7
8
|
|
|
@@ -15,6 +16,18 @@ module Parse
|
|
|
15
16
|
include Parse::Protocol
|
|
16
17
|
# @!visibility private
|
|
17
18
|
DISABLE_MASTER_KEY = "X-Disable-Parse-Master-Key".freeze
|
|
19
|
+
# SDK-internal marker for a request that reads metadata (a class
|
|
20
|
+
# schema, the role graph) while enforcing a scope. Such a request keeps
|
|
21
|
+
# the master key inside {Parse.without_master_key}, which governs row
|
|
22
|
+
# access. The header carries a random per-process token and is removed
|
|
23
|
+
# before the request is logged or sent, so a header copied from user
|
|
24
|
+
# input cannot set it. {Parse::Client#request} adds it only for the
|
|
25
|
+
# `metadata_master:` request option set to
|
|
26
|
+
# {Parse::Client::METADATA_MASTER_REQUEST}.
|
|
27
|
+
# @!visibility private
|
|
28
|
+
METADATA_MASTER = "X-Parse-Stack-Metadata-Master".freeze
|
|
29
|
+
# @!visibility private
|
|
30
|
+
METADATA_MASTER_TOKEN = SecureRandom.hex(32).freeze
|
|
18
31
|
# @return [String] the application id for this Parse endpoint.
|
|
19
32
|
attr_accessor :application_id
|
|
20
33
|
# @return [String] the REST API Key for this Parse endpoint.
|
|
@@ -67,7 +80,12 @@ module Parse
|
|
|
67
80
|
# 3. A session-token-authenticated request (the existing check
|
|
68
81
|
# below; session token wins over master key).
|
|
69
82
|
header_disable = env[:request_headers][DISABLE_MASTER_KEY].present?
|
|
70
|
-
|
|
83
|
+
# An SDK metadata read keeps the master key inside the block. Always
|
|
84
|
+
# remove the marker so it never reaches the logger or the server.
|
|
85
|
+
metadata_value = env[:request_headers].delete(METADATA_MASTER)
|
|
86
|
+
metadata_master = metadata_value.is_a?(String) &&
|
|
87
|
+
ActiveSupport::SecurityUtils.secure_compare(metadata_value, METADATA_MASTER_TOKEN)
|
|
88
|
+
fiber_disable = Parse.master_key_disabled? && !metadata_master
|
|
71
89
|
unless @master_key.blank? || header_disable || fiber_disable
|
|
72
90
|
headers[MASTER_KEY] = @master_key
|
|
73
91
|
end
|