parse-stack-next 5.6.0 → 5.7.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/CHANGELOG.md +409 -0
- data/README.md +378 -6
- data/docs/caching.md +748 -0
- data/examples/basic_client.rb +3 -3
- data/examples/basic_server.rb +3 -3
- data/examples/live_query_listener.rb +12 -12
- data/examples/rag_chatbot.rb +14 -14
- data/examples/transaction_example.rb +44 -45
- data/examples/webhook_server.rb +3 -3
- data/lib/parse/access.rb +493 -0
- data/lib/parse/acl_scope.rb +125 -52
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/cancellation_token.rb +3 -3
- data/lib/parse/agent/constraint_translator.rb +4 -4
- data/lib/parse/agent/describe.rb +34 -34
- data/lib/parse/agent/errors.rb +9 -9
- data/lib/parse/agent/mcp_client.rb +61 -59
- data/lib/parse/agent/mcp_dispatcher.rb +89 -101
- data/lib/parse/agent/mcp_rack_app.rb +92 -93
- data/lib/parse/agent/mcp_server.rb +1 -1
- data/lib/parse/agent/mcp_subscriptions.rb +22 -22
- data/lib/parse/agent/metadata_audit.rb +1 -2
- data/lib/parse/agent/metadata_dsl.rb +7 -7
- data/lib/parse/agent/metadata_registry.rb +12 -12
- data/lib/parse/agent/prompt_hardening.rb +4 -4
- data/lib/parse/agent/prompts.rb +10 -10
- data/lib/parse/agent/result_formatter.rb +4 -4
- data/lib/parse/agent/tools.rb +493 -400
- data/lib/parse/agent.rb +157 -116
- data/lib/parse/api/hooks.rb +1 -1
- data/lib/parse/api/server.rb +2 -2
- data/lib/parse/api/users.rb +2 -2
- data/lib/parse/atlas_search/index_manager.rb +1 -1
- data/lib/parse/atlas_search/session.rb +40 -218
- data/lib/parse/atlas_search.rb +181 -60
- data/lib/parse/authorization.rb +466 -0
- data/lib/parse/cache/invalidation.rb +167 -0
- data/lib/parse/cache/keyspace.rb +306 -0
- data/lib/parse/cache/moneta_surface.rb +126 -0
- data/lib/parse/cache/pool.rb +48 -5
- data/lib/parse/cache/redis.rb +425 -10
- data/lib/parse/cache/scoped_view.rb +443 -0
- data/lib/parse/cache/sub_cache.rb +264 -0
- data/lib/parse/cache/upstream_roles.rb +230 -0
- data/lib/parse/client/authentication.rb +1 -1
- data/lib/parse/client/body_builder.rb +1 -2
- data/lib/parse/client/caching.rb +84 -5
- data/lib/parse/client.rb +327 -189
- data/lib/parse/clp_scope.rb +225 -28
- data/lib/parse/console.rb +3 -3
- data/lib/parse/embeddings/batch_embedder.rb +3 -3
- data/lib/parse/embeddings/cache.rb +17 -17
- data/lib/parse/embeddings/cohere.rb +21 -22
- data/lib/parse/embeddings/image_fetch.rb +22 -21
- data/lib/parse/embeddings/jina.rb +21 -21
- data/lib/parse/embeddings/local_http.rb +6 -7
- data/lib/parse/embeddings/media_file.rb +5 -4
- data/lib/parse/embeddings/openai.rb +5 -5
- data/lib/parse/embeddings/provider.rb +1 -1
- data/lib/parse/embeddings/qwen.rb +10 -10
- data/lib/parse/embeddings/spend_cap.rb +2 -3
- data/lib/parse/embeddings/streaming_body.rb +11 -11
- data/lib/parse/embeddings/video_source.rb +7 -6
- data/lib/parse/embeddings/voyage.rb +59 -61
- data/lib/parse/embeddings.rb +28 -27
- data/lib/parse/graphql/type_generator.rb +1 -1
- data/lib/parse/graphql.rb +5 -5
- data/lib/parse/live_query/client.rb +1 -1
- data/lib/parse/live_query.rb +1 -1
- data/lib/parse/lock.rb +15 -18
- data/lib/parse/lock_backend.rb +1 -1
- data/lib/parse/lookup_rewriter.rb +6 -6
- data/lib/parse/model/classes/role.rb +227 -28
- data/lib/parse/model/classes/user.rb +60 -1
- data/lib/parse/model/clp.rb +4 -4
- data/lib/parse/model/core/actions.rb +97 -15
- data/lib/parse/model/core/create_lock.rb +0 -2
- data/lib/parse/model/core/describe.rb +57 -57
- data/lib/parse/model/core/embed_managed.rb +21 -23
- data/lib/parse/model/core/fetching.rb +2 -0
- data/lib/parse/model/core/indexing.rb +14 -14
- data/lib/parse/model/core/parse_reference.rb +1 -1
- data/lib/parse/model/core/properties.rb +2 -2
- data/lib/parse/model/core/querying.rb +1 -1
- data/lib/parse/model/core/schema.rb +2 -2
- data/lib/parse/model/core/search_indexing.rb +2 -2
- data/lib/parse/model/core/vector_searchable.rb +4 -5
- data/lib/parse/model/file.rb +21 -15
- data/lib/parse/model/geojson.rb +2 -2
- data/lib/parse/model/geopoint.rb +1 -0
- data/lib/parse/model/object.rb +132 -66
- data/lib/parse/model/pointer.rb +2 -0
- data/lib/parse/model/polygon.rb +3 -6
- data/lib/parse/model/push.rb +2 -2
- data/lib/parse/model/vector.rb +3 -1
- data/lib/parse/mongodb.rb +512 -282
- data/lib/parse/pipeline_security.rb +3 -0
- data/lib/parse/query/constraints.rb +46 -46
- data/lib/parse/query.rb +146 -59
- data/lib/parse/retrieval/agent_tool.rb +34 -20
- data/lib/parse/retrieval/chunk.rb +1 -0
- data/lib/parse/retrieval/reranker/cohere.rb +11 -11
- data/lib/parse/retrieval/reranker.rb +3 -4
- data/lib/parse/retrieval/retriever.rb +4 -6
- data/lib/parse/schema/index_migrator.rb +26 -26
- data/lib/parse/schema/search_index_migrator.rb +19 -19
- data/lib/parse/stack/tasks.rb +6 -6
- data/lib/parse/stack/version.rb +1 -1
- data/lib/parse/stack.rb +10 -10
- data/lib/parse/vector_search/hybrid.rb +66 -41
- data/lib/parse/vector_search.rb +14 -11
- data/lib/parse/webhooks/payload.rb +1 -0
- data/lib/parse/webhooks/registration.rb +4 -4
- data/lib/parse/webhooks/trigger_audit.rb +50 -49
- data/lib/parse/webhooks.rb +22 -4
- data/parse-stack-next.gemspec +6 -6
- metadata +10 -1
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "set"
|
|
5
|
+
require "digest"
|
|
6
|
+
|
|
7
|
+
module Parse
|
|
8
|
+
# Resolution of a caller's identity and inherited roles, and the caches that
|
|
9
|
+
# make that resolution cheap.
|
|
10
|
+
#
|
|
11
|
+
# **Why this is not part of Atlas Search.** It used to be. Session-token
|
|
12
|
+
# resolution and role-closure expansion were written for
|
|
13
|
+
# `Parse::AtlasSearch`, because `$search` was the first thing that ran
|
|
14
|
+
# aggregations straight against MongoDB and therefore the first thing that
|
|
15
|
+
# had to enforce ACLs itself. Everything since has reached back through it:
|
|
16
|
+
# `Parse::ACLScope` called `Parse::AtlasSearch::Session.resolve`, and
|
|
17
|
+
# `Parse::MongoDB.aggregate` calls `Parse::ACLScope`, so
|
|
18
|
+
# `Parse::Query#results_direct` on a plain query with no `$search` anywhere
|
|
19
|
+
# in it depended on the Atlas Search namespace to decide who the caller was.
|
|
20
|
+
# That is backwards. Deciding who someone is and what they may read is
|
|
21
|
+
# authorization infrastructure, and Atlas Search is one consumer of it:
|
|
22
|
+
#
|
|
23
|
+
# Atlas Search ─┐
|
|
24
|
+
# Aggregates ──┼─> Parse::Authorization ─> identity / role caches
|
|
25
|
+
# Direct query ─┘
|
|
26
|
+
#
|
|
27
|
+
# Policy that is genuinely about Atlas Search stays on Atlas Search:
|
|
28
|
+
# `Parse::AtlasSearch.require_session_token` decides whether `$search` may
|
|
29
|
+
# run anonymously, which is a question about that feature, not about
|
|
30
|
+
# identity.
|
|
31
|
+
#
|
|
32
|
+
# **Two caches, because they invalidate on different events.**
|
|
33
|
+
#
|
|
34
|
+
# * The **identity plane** maps a session token to a user id. Long TTL
|
|
35
|
+
# (1 hour), invalidated by logout and by a `_User` write. Named
|
|
36
|
+
# `identity_cache` rather than `session_cache` because it stores neither
|
|
37
|
+
# `_Session` rows nor session objects: it stores one string per token.
|
|
38
|
+
# The old name led readers to reason about `_Session` semantics that
|
|
39
|
+
# were never involved.
|
|
40
|
+
#
|
|
41
|
+
# * The **role plane** maps a user id to a `Set` of role names. Short TTL
|
|
42
|
+
# (30 seconds), invalidated by any `_Role` write. Stale entries here
|
|
43
|
+
# produce wrong ACL decisions rather than merely slow ones, so the
|
|
44
|
+
# default is conservative.
|
|
45
|
+
#
|
|
46
|
+
# **State belongs to a client, not to the process.** A {Context} is owned by
|
|
47
|
+
# one {Parse::Client} and reachable as `client.authorization`. Two named
|
|
48
|
+
# clients pointed at two Parse applications must never resolve a token
|
|
49
|
+
# against each other's caches or each other's `/users/me`, which is exactly
|
|
50
|
+
# what a set of module-level globals allowed. {Parse::Authorization.configure}
|
|
51
|
+
# exists as a boundary convenience and configures the DEFAULT client's
|
|
52
|
+
# context; below the boundary, `client:` is required and has no default.
|
|
53
|
+
module Authorization
|
|
54
|
+
# Raised when a `session_token` cannot be resolved: an invalid token, an
|
|
55
|
+
# expired session, or a `/users/me` that returned an error. Callers should
|
|
56
|
+
# treat it as a 401-equivalent.
|
|
57
|
+
class InvalidSession < StandardError; end
|
|
58
|
+
|
|
59
|
+
# Default cache: a process-local hash with per-entry TTL, guarded by a
|
|
60
|
+
# `Mutex`. Fine for a single process. Multi-process deployments (Puma
|
|
61
|
+
# workers, Sidekiq processes) get one of these per process and should
|
|
62
|
+
# install a shared plane instead, which is what
|
|
63
|
+
# `Parse::Cache::Redis#scoped(...).identity` / `.roles` return.
|
|
64
|
+
class MemoryCache
|
|
65
|
+
def initialize
|
|
66
|
+
@data = {}
|
|
67
|
+
@mutex = Mutex.new
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# @param key [String]
|
|
71
|
+
# @return [Object, nil] the cached value, or `nil` when the key is
|
|
72
|
+
# missing or its TTL has elapsed. Expired entries are evicted lazily
|
|
73
|
+
# on read.
|
|
74
|
+
def get(key)
|
|
75
|
+
@mutex.synchronize do
|
|
76
|
+
entry = @data[key]
|
|
77
|
+
return nil if entry.nil?
|
|
78
|
+
if entry[:expires_at] < Time.now
|
|
79
|
+
@data.delete(key)
|
|
80
|
+
return nil
|
|
81
|
+
end
|
|
82
|
+
entry[:value]
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# @param key [String]
|
|
87
|
+
# @param value [Object]
|
|
88
|
+
# @param ttl [Numeric] seconds until the entry expires.
|
|
89
|
+
def set(key, value, ttl:)
|
|
90
|
+
@mutex.synchronize do
|
|
91
|
+
@data[key] = { value: value, expires_at: Time.now + ttl }
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# @param key [String] cache key to forget.
|
|
96
|
+
def invalidate(key)
|
|
97
|
+
@mutex.synchronize { @data.delete(key) }
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Drop every entry.
|
|
101
|
+
def clear
|
|
102
|
+
@mutex.synchronize { @data.clear }
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# The outcome of resolving a caller. `user_id` is the `_User.objectId`
|
|
107
|
+
# owning the session, or `nil` for an anonymous caller. `role_names` is a
|
|
108
|
+
# `Set` of bare role names (no `role:` prefix) the user inherits
|
|
109
|
+
# permissions from.
|
|
110
|
+
Resolved = Struct.new(:user_id, :role_names) do
|
|
111
|
+
# The canonical `_rperm` / `_wperm` permission-string set for this
|
|
112
|
+
# caller. Always includes `"*"`. Includes `user_id` when present, and
|
|
113
|
+
# `"role:#{name}"` for each inherited role.
|
|
114
|
+
# @return [Array<String>]
|
|
115
|
+
def permission_strings
|
|
116
|
+
out = ["*"]
|
|
117
|
+
out << user_id if user_id && !user_id.empty?
|
|
118
|
+
role_names.each { |name| out << "role:#{name}" if name && !name.empty? }
|
|
119
|
+
out.uniq
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# @return [Boolean] `true` for the anonymous case.
|
|
123
|
+
def anonymous?
|
|
124
|
+
user_id.nil? || user_id.empty?
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Per-client authorization state: the two caches, their TTLs, and the
|
|
129
|
+
# optional upstream-role reader.
|
|
130
|
+
#
|
|
131
|
+
# One of these is owned by each {Parse::Client}. It deliberately does NOT
|
|
132
|
+
# own the HTTP client: it holds a back-reference and asks the client to
|
|
133
|
+
# make the `/users/me` call, so there is exactly one place that knows how
|
|
134
|
+
# to talk to a Parse application and it is the client itself.
|
|
135
|
+
class Context
|
|
136
|
+
# @return [Object] the identity plane. Maps session token to user id.
|
|
137
|
+
attr_accessor :identity_cache
|
|
138
|
+
|
|
139
|
+
# @return [Object] the role plane. Maps user id to a Set of role names.
|
|
140
|
+
attr_accessor :role_cache
|
|
141
|
+
|
|
142
|
+
# @return [Integer] identity-entry TTL in seconds.
|
|
143
|
+
attr_accessor :identity_cache_ttl
|
|
144
|
+
|
|
145
|
+
# @return [Integer] role-entry TTL in seconds.
|
|
146
|
+
attr_accessor :role_cache_ttl
|
|
147
|
+
|
|
148
|
+
# @return [#roles_for, nil] read-only reader for Parse Server's own role
|
|
149
|
+
# cache. Never consumed for authorization; see {#compare_upstream_roles}.
|
|
150
|
+
attr_accessor :upstream_role_reader
|
|
151
|
+
|
|
152
|
+
# @return [Boolean] when true, and a reader is set, every role
|
|
153
|
+
# resolution also reads the upstream closure and emits a
|
|
154
|
+
# `parse.cache.role_compare` event. The comparison NEVER changes what
|
|
155
|
+
# {#resolve} returns. The upstream value would become an authorization
|
|
156
|
+
# input the moment it were consumed, and it comes from a database this
|
|
157
|
+
# SDK does not own, so it stays observable-only until the two closures
|
|
158
|
+
# have been reconciled against real traffic.
|
|
159
|
+
attr_accessor :compare_upstream_roles
|
|
160
|
+
|
|
161
|
+
# @return [Parse::Client] the client this context authorizes for.
|
|
162
|
+
attr_reader :client
|
|
163
|
+
|
|
164
|
+
DEFAULT_IDENTITY_TTL = 3600
|
|
165
|
+
DEFAULT_ROLE_TTL = 30
|
|
166
|
+
|
|
167
|
+
# Depth cap for the role-graph walk. Bounds a cyclic or pathological
|
|
168
|
+
# hierarchy; see {Parse::Role.all_for_user}.
|
|
169
|
+
ROLE_GRAPH_MAX_DEPTH = 10
|
|
170
|
+
|
|
171
|
+
def initialize(client:)
|
|
172
|
+
@client = client
|
|
173
|
+
@identity_cache = MemoryCache.new
|
|
174
|
+
@role_cache = MemoryCache.new
|
|
175
|
+
@identity_cache_ttl = DEFAULT_IDENTITY_TTL
|
|
176
|
+
@role_cache_ttl = DEFAULT_ROLE_TTL
|
|
177
|
+
@upstream_role_reader = nil
|
|
178
|
+
@compare_upstream_roles = false
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Apply settings, leaving anything not passed unchanged.
|
|
182
|
+
# @return [self]
|
|
183
|
+
def configure(identity_cache: nil, role_cache: nil,
|
|
184
|
+
identity_cache_ttl: nil, role_cache_ttl: nil,
|
|
185
|
+
upstream_role_reader: nil, compare_upstream_roles: nil)
|
|
186
|
+
@identity_cache = identity_cache unless identity_cache.nil?
|
|
187
|
+
@role_cache = role_cache unless role_cache.nil?
|
|
188
|
+
@identity_cache_ttl = identity_cache_ttl unless identity_cache_ttl.nil?
|
|
189
|
+
@role_cache_ttl = role_cache_ttl unless role_cache_ttl.nil?
|
|
190
|
+
@upstream_role_reader = upstream_role_reader unless upstream_role_reader.nil?
|
|
191
|
+
@compare_upstream_roles = compare_upstream_roles unless compare_upstream_roles.nil?
|
|
192
|
+
self
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Resolve a session token to the requesting user and the transitive set
|
|
196
|
+
# of role names whose `role:NAME` permission strings should be checked
|
|
197
|
+
# against `_rperm`.
|
|
198
|
+
#
|
|
199
|
+
# A `nil` or empty token yields an anonymous {Resolved}. The caller
|
|
200
|
+
# decides whether that is acceptable; `Parse::ACLScope.require_session_token`
|
|
201
|
+
# and `Parse::AtlasSearch.require_session_token` are where that policy
|
|
202
|
+
# lives.
|
|
203
|
+
#
|
|
204
|
+
# The two lookups are cached independently, so several sessions
|
|
205
|
+
# belonging to one user share a single role-graph walk.
|
|
206
|
+
#
|
|
207
|
+
# @param session_token [String, nil]
|
|
208
|
+
# @return [Resolved]
|
|
209
|
+
# @raise [InvalidSession] when `/users/me` cannot resolve the token.
|
|
210
|
+
def resolve(session_token)
|
|
211
|
+
return Resolved.new(nil, Set.new) if session_token.nil? || session_token.to_s.empty?
|
|
212
|
+
|
|
213
|
+
user_id = lookup_user_id(session_token.to_s)
|
|
214
|
+
Resolved.new(user_id, lookup_role_names(user_id))
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
# Resolve a user id that is already trusted, skipping `/users/me`.
|
|
218
|
+
# Used by the `acl_user:` path, which has a User pointer rather than a
|
|
219
|
+
# token.
|
|
220
|
+
# @param user_id [String]
|
|
221
|
+
# @return [Resolved]
|
|
222
|
+
def resolve_user(user_id)
|
|
223
|
+
return Resolved.new(nil, Set.new) if user_id.nil? || user_id.to_s.empty?
|
|
224
|
+
Resolved.new(user_id.to_s, lookup_role_names(user_id.to_s))
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
# Forget one session token. Call from a logout path that revokes
|
|
228
|
+
# out-of-band; `Parse::Cache::Invalidation` does this automatically from
|
|
229
|
+
# the `_Session` `after_logout` trigger when webhooks are installed.
|
|
230
|
+
#
|
|
231
|
+
# The role plane is keyed by user id and is unaffected; use
|
|
232
|
+
# {#invalidate_user_roles} for that.
|
|
233
|
+
# @param session_token [String]
|
|
234
|
+
def invalidate(session_token)
|
|
235
|
+
return if session_token.nil?
|
|
236
|
+
@identity_cache.invalidate(session_token.to_s)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# Forget one user's cached role closure. Call after any `_Role.users`
|
|
240
|
+
# mutation affecting them.
|
|
241
|
+
# @param user_id [String]
|
|
242
|
+
def invalidate_user_roles(user_id)
|
|
243
|
+
return if user_id.nil?
|
|
244
|
+
@role_cache.invalidate(user_id.to_s)
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Drop every entry in both planes.
|
|
248
|
+
def reset_caches!
|
|
249
|
+
@identity_cache.clear if @identity_cache.respond_to?(:clear)
|
|
250
|
+
@role_cache.clear if @role_cache.respond_to?(:clear)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
def inspect
|
|
254
|
+
"#<Parse::Authorization::Context client=#{@client.respond_to?(:application_id) ? @client.application_id : @client.class}>"
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
private
|
|
258
|
+
|
|
259
|
+
# Resolve token to user id through the identity plane, falling through
|
|
260
|
+
# to `/users/me` on this context's OWN client. Threading the client here
|
|
261
|
+
# is the substance of the refactor: a global resolver would have asked
|
|
262
|
+
# `Parse.client`, so a token minted by a secondary application would be
|
|
263
|
+
# validated against the default application and either fail or, worse,
|
|
264
|
+
# match a different user with the same token shape.
|
|
265
|
+
def lookup_user_id(session_token)
|
|
266
|
+
cached = cached_user_id(session_token)
|
|
267
|
+
return cached unless cached.nil?
|
|
268
|
+
|
|
269
|
+
response = begin
|
|
270
|
+
@client.current_user(session_token)
|
|
271
|
+
rescue => e
|
|
272
|
+
raise InvalidSession, "session token lookup failed: #{e.class}: #{e.message}"
|
|
273
|
+
end
|
|
274
|
+
raise InvalidSession, "session token invalid or expired" if response.nil? || response.error?
|
|
275
|
+
|
|
276
|
+
result = response.result
|
|
277
|
+
user_id = result.is_a?(Hash) ? (result["objectId"] || result[:objectId]) : nil
|
|
278
|
+
raise InvalidSession, "session token resolved no user objectId" if user_id.nil? || user_id.to_s.empty?
|
|
279
|
+
|
|
280
|
+
user_id = user_id.to_s
|
|
281
|
+
store_user_id(session_token, user_id)
|
|
282
|
+
user_id
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Read the identity plane and, where the plane supports it, check that
|
|
286
|
+
# the entry's generation is still current.
|
|
287
|
+
#
|
|
288
|
+
# A `_User` write bumps that generation (see
|
|
289
|
+
# {Parse::Cache::Invalidation}), so a modified or revoked user's cached
|
|
290
|
+
# entries are rejected on the very next read instead of staying
|
|
291
|
+
# resolvable for the rest of {#identity_cache_ttl}. The default
|
|
292
|
+
# {MemoryCache} has no generation contract, so this feature-detects and
|
|
293
|
+
# falls back to trusting the bare value, adding no round trip.
|
|
294
|
+
#
|
|
295
|
+
# @return [String, nil] `nil` on any miss: absent, stale generation, or
|
|
296
|
+
# a shape this reader does not recognize. An unrecognized shape
|
|
297
|
+
# includes a bare `String` written by a generation-capable plane
|
|
298
|
+
# before generations existed; treating it as a miss costs one
|
|
299
|
+
# re-resolution rather than trusting it unchecked.
|
|
300
|
+
def cached_user_id(session_token)
|
|
301
|
+
cache = @identity_cache
|
|
302
|
+
raw = cache.get(session_token)
|
|
303
|
+
return nil if raw.nil?
|
|
304
|
+
|
|
305
|
+
unless generation_capable?(cache)
|
|
306
|
+
return raw.is_a?(String) ? raw : nil
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
return nil unless raw.is_a?(Hash)
|
|
310
|
+
user_id = raw["user_id"] || raw[:user_id]
|
|
311
|
+
gen = raw.key?("gen") ? raw["gen"] : raw[:gen]
|
|
312
|
+
return nil if user_id.nil? || gen.nil?
|
|
313
|
+
return nil unless cache.generation_current?(user_id, gen)
|
|
314
|
+
user_id
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
# Write the identity entry, tagging it with the subject's current
|
|
318
|
+
# generation when the plane can track one.
|
|
319
|
+
def store_user_id(session_token, user_id)
|
|
320
|
+
cache = @identity_cache
|
|
321
|
+
if generation_capable?(cache)
|
|
322
|
+
cache.set(session_token, { "user_id" => user_id, "gen" => cache.generation(user_id) },
|
|
323
|
+
ttl: @identity_cache_ttl)
|
|
324
|
+
else
|
|
325
|
+
cache.set(session_token, user_id, ttl: @identity_cache_ttl)
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
def generation_capable?(cache)
|
|
330
|
+
cache.respond_to?(:generation) && cache.respond_to?(:generation_current?)
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Resolve user id to a Set of role names through the role plane,
|
|
334
|
+
# falling through to {Parse::Role.all_for_user}.
|
|
335
|
+
#
|
|
336
|
+
# Ordinary failures degrade to an empty set rather than raising: a
|
|
337
|
+
# Parse Server hiccup during the role walk must not turn every query
|
|
338
|
+
# into a 500, and the cost is a query that misses role-restricted rows.
|
|
339
|
+
#
|
|
340
|
+
# The three re-raised classes are deliberate exceptions to that. A
|
|
341
|
+
# denied-operator probe, a timeout exhaustion, and a CLP denial are
|
|
342
|
+
# attack signals or explicit policy denials. Swallowing them would
|
|
343
|
+
# downgrade the caller to public-only permissions AND hide the signal
|
|
344
|
+
# from the operator, which is the worst of both.
|
|
345
|
+
def lookup_role_names(user_id)
|
|
346
|
+
return Set.new if user_id.nil? || user_id.empty?
|
|
347
|
+
|
|
348
|
+
cached = @role_cache.get(user_id)
|
|
349
|
+
if cached.is_a?(Set)
|
|
350
|
+
compare_with_upstream(user_id, cached)
|
|
351
|
+
return cached
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
pointer = Parse::Pointer.new(Parse::Model::CLASS_USER, user_id)
|
|
355
|
+
names = begin
|
|
356
|
+
# `client:` matters as much here as it does for the token
|
|
357
|
+
# lookup above. Without it the identity resolves against THIS
|
|
358
|
+
# client while its role closure is walked against the default
|
|
359
|
+
# application, so a user of application B would be granted
|
|
360
|
+
# application A's roles by name.
|
|
361
|
+
Parse::Role.all_for_user(pointer, max_depth: ROLE_GRAPH_MAX_DEPTH, client: @client)
|
|
362
|
+
rescue Parse::MongoDB::DeniedOperator,
|
|
363
|
+
Parse::MongoDB::ExecutionTimeout,
|
|
364
|
+
Parse::CLPScope::Denied
|
|
365
|
+
raise
|
|
366
|
+
rescue
|
|
367
|
+
Set.new
|
|
368
|
+
end
|
|
369
|
+
@role_cache.set(user_id, names, ttl: @role_cache_ttl)
|
|
370
|
+
compare_with_upstream(user_id, names)
|
|
371
|
+
names
|
|
372
|
+
end
|
|
373
|
+
|
|
374
|
+
# Opt-in, compare-only read of Parse Server's own role cache. This never
|
|
375
|
+
# changes what {#lookup_role_names} returns: `computed` is already
|
|
376
|
+
# decided by the time this runs. Its only job is to emit an event so the
|
|
377
|
+
# two closures can be compared out-of-band before anything is switched
|
|
378
|
+
# to consume the upstream value.
|
|
379
|
+
#
|
|
380
|
+
# Inert unless both the switch and a reader are set, so it costs one
|
|
381
|
+
# boolean check when off. Every exception is swallowed: this is
|
|
382
|
+
# instrumentation and must never affect resolution.
|
|
383
|
+
def compare_with_upstream(user_id, computed)
|
|
384
|
+
return unless @compare_upstream_roles
|
|
385
|
+
reader = @upstream_role_reader
|
|
386
|
+
return if reader.nil?
|
|
387
|
+
|
|
388
|
+
upstream = begin
|
|
389
|
+
reader.roles_for(user_id)
|
|
390
|
+
rescue StandardError
|
|
391
|
+
nil
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
emit_role_compare(user_id, computed, upstream)
|
|
395
|
+
nil
|
|
396
|
+
rescue StandardError
|
|
397
|
+
nil
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
# Emit `parse.cache.role_compare`. Follows the redaction discipline of
|
|
401
|
+
# `Parse::Middleware::Caching#instrument_cache` and
|
|
402
|
+
# `Parse::CreateLock#instrument`: no role names and no raw user id in
|
|
403
|
+
# the payload, a truncated digest only.
|
|
404
|
+
def emit_role_compare(user_id, computed, upstream)
|
|
405
|
+
return unless defined?(ActiveSupport::Notifications)
|
|
406
|
+
|
|
407
|
+
upstream_nil = upstream.nil?
|
|
408
|
+
only_in_ours = upstream_nil ? computed.size : (computed - upstream).size
|
|
409
|
+
only_in_upstream = upstream_nil ? 0 : (upstream - computed).size
|
|
410
|
+
|
|
411
|
+
ActiveSupport::Notifications.instrument("parse.cache.role_compare", {
|
|
412
|
+
user_digest: Digest::SHA256.hexdigest(user_id.to_s)[0, 16],
|
|
413
|
+
upstream_nil: upstream_nil,
|
|
414
|
+
matched: !upstream_nil && only_in_ours.zero? && only_in_upstream.zero?,
|
|
415
|
+
computed_size: computed.size,
|
|
416
|
+
upstream_size: upstream_nil ? nil : upstream.size,
|
|
417
|
+
only_in_ours: only_in_ours,
|
|
418
|
+
only_in_upstream: only_in_upstream,
|
|
419
|
+
})
|
|
420
|
+
nil
|
|
421
|
+
rescue StandardError
|
|
422
|
+
nil
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
class << self
|
|
427
|
+
# Configure the DEFAULT client's authorization context.
|
|
428
|
+
#
|
|
429
|
+
# This is a boundary convenience, matching the shape already used for
|
|
430
|
+
# `Parse::AtlasSearch.search(..., client: Parse.client)`: the common
|
|
431
|
+
# single-application case should not have to name the client. It is
|
|
432
|
+
# explicitly NOT the source of truth. The state lives on
|
|
433
|
+
# `client.authorization`, one context per client, which is what stops
|
|
434
|
+
# two named clients resolving tokens against each other's caches. To
|
|
435
|
+
# configure a secondary application, call
|
|
436
|
+
# `other_client.authorization.configure(...)` directly.
|
|
437
|
+
#
|
|
438
|
+
# @return [Parse::Authorization::Context] the default client's context.
|
|
439
|
+
def configure(**kwargs)
|
|
440
|
+
Parse.client.authorization.configure(**kwargs)
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Resolve a session token against a specific client.
|
|
444
|
+
#
|
|
445
|
+
# `client:` is required and has no default. Below the API boundary
|
|
446
|
+
# there is no such thing as "the" client, and defaulting to
|
|
447
|
+
# `Parse.client` here is precisely the bug this module exists to close:
|
|
448
|
+
# a token belonging to application B would be validated against
|
|
449
|
+
# application A.
|
|
450
|
+
#
|
|
451
|
+
# @param session_token [String, nil]
|
|
452
|
+
# @param client [Parse::Client]
|
|
453
|
+
# @return [Resolved]
|
|
454
|
+
def resolve(session_token, client:)
|
|
455
|
+
raise ArgumentError, "Parse::Authorization.resolve requires client:" if client.nil?
|
|
456
|
+
client.authorization.resolve(session_token)
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# @see Context#resolve_user
|
|
460
|
+
def resolve_user(user_id, client:)
|
|
461
|
+
raise ArgumentError, "Parse::Authorization.resolve_user requires client:" if client.nil?
|
|
462
|
+
client.authorization.resolve_user(user_id)
|
|
463
|
+
end
|
|
464
|
+
end
|
|
465
|
+
end
|
|
466
|
+
end
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
module Cache
|
|
6
|
+
# Registers the webhook triggers that keep the identity and role planes
|
|
7
|
+
# honest without relying on application discipline.
|
|
8
|
+
#
|
|
9
|
+
# Before this, the documented contract asked applications to call
|
|
10
|
+
# `Session.invalidate` and `invalidate_user_roles` from their own logout and
|
|
11
|
+
# role-mutation paths. That depends on every application remembering, and it
|
|
12
|
+
# misses role changes made by any other client: a mobile SDK, the dashboard,
|
|
13
|
+
# or Node cloud code. Registering our own triggers moves the responsibility
|
|
14
|
+
# to the SDK and covers writes from every source Parse Server sees.
|
|
15
|
+
#
|
|
16
|
+
# Parse::Cache::Invalidation.install!(cache)
|
|
17
|
+
#
|
|
18
|
+
# The TTL remains the backstop. These triggers require the application to
|
|
19
|
+
# run a webhook endpoint Parse Server can reach and to have registered the
|
|
20
|
+
# hooks; unregistered or unreachable, the TTL is the only bound on
|
|
21
|
+
# staleness. TTL *and* hooks, not TTL or hooks.
|
|
22
|
+
module Invalidation
|
|
23
|
+
# Classes we register against, exposed for tests and diagnostics.
|
|
24
|
+
# Literal class names rather than Parse::Model constants: this file is
|
|
25
|
+
# required from Parse::Client before Parse::Model is defined, and these
|
|
26
|
+
# are fixed Parse Server protocol names, not app-domain classes.
|
|
27
|
+
USER_CLASS = "_User"
|
|
28
|
+
SESSION_CLASS = "_Session"
|
|
29
|
+
ROLE_CLASS = "_Role"
|
|
30
|
+
|
|
31
|
+
TRIGGERS = {
|
|
32
|
+
role: [[:after_save, ROLE_CLASS], [:after_delete, ROLE_CLASS]],
|
|
33
|
+
identity: [[:after_save, USER_CLASS],
|
|
34
|
+
[:after_delete, USER_CLASS],
|
|
35
|
+
[:after_logout, SESSION_CLASS]],
|
|
36
|
+
}.freeze
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
# Install the triggers for a cache exposing `roles` and `identity`
|
|
40
|
+
# planes.
|
|
41
|
+
#
|
|
42
|
+
# @param cache [Parse::Cache::Redis] a keyspace-configured cache.
|
|
43
|
+
# @return [Array<Array>] the routes registered.
|
|
44
|
+
def install!(cache)
|
|
45
|
+
unless cache.respond_to?(:roles) && cache.respond_to?(:identity)
|
|
46
|
+
raise ArgumentError,
|
|
47
|
+
"Parse::Cache::Invalidation requires a cache with role and identity planes"
|
|
48
|
+
end
|
|
49
|
+
registered = []
|
|
50
|
+
registered.concat(install_role_triggers!(cache))
|
|
51
|
+
registered.concat(install_identity_triggers!(cache))
|
|
52
|
+
registered
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def install_role_triggers!(cache)
|
|
58
|
+
TRIGGERS[:role].map do |(type, class_name)|
|
|
59
|
+
Parse::Webhooks.route(type, class_name) do |payload|
|
|
60
|
+
guard do
|
|
61
|
+
# A role write does not say which users are affected: membership
|
|
62
|
+
# and hierarchy changes arrive as relation deltas on `users` and
|
|
63
|
+
# `roles`, and the cached value is a flattened transitive closure,
|
|
64
|
+
# so a parent-role change reaches the members of every child.
|
|
65
|
+
# Clearing the whole plane is both correct and cheap under a
|
|
66
|
+
# scoped SCAN. Parse Server does the same, for the same reason.
|
|
67
|
+
cache.roles.clear
|
|
68
|
+
# Stamp the epoch so a *foreign* role entry written before this
|
|
69
|
+
# moment is rejected on read. Parse Server does not clear its own
|
|
70
|
+
# role cache on a `_Role` delete, so without this the next read
|
|
71
|
+
# would take its stale entry back and our clear would shorten
|
|
72
|
+
# revocation by nothing.
|
|
73
|
+
cache.roles.touch_epoch
|
|
74
|
+
end
|
|
75
|
+
true
|
|
76
|
+
end
|
|
77
|
+
[type, class_name]
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def install_identity_triggers!(cache)
|
|
82
|
+
TRIGGERS[:identity].map do |(type, class_name)|
|
|
83
|
+
Parse::Webhooks.route(type, class_name) do |payload|
|
|
84
|
+
guard do
|
|
85
|
+
case type
|
|
86
|
+
when :after_logout
|
|
87
|
+
# The only trigger Parse Server permits on `_Session`. The
|
|
88
|
+
# object's own sessionToken is scrubbed from the payload, but
|
|
89
|
+
# the token is captured from the requesting user before
|
|
90
|
+
# scrubbing, and for a logout that user *is* the session being
|
|
91
|
+
# ended. A master-key logout carries no user, so fall back to
|
|
92
|
+
# the generation bump.
|
|
93
|
+
#
|
|
94
|
+
# Pass the RAW token, not a pre-hashed digest.
|
|
95
|
+
# `Parse::Cache::SubCache#invalidate` hashes its `key`
|
|
96
|
+
# argument internally for the `:idn` family (see
|
|
97
|
+
# `SubCache#logical_key`), the same way `#get` / `#set` do —
|
|
98
|
+
# that is what makes a `set(raw_token, ...)` /
|
|
99
|
+
# `get(raw_token)` pair round-trip. Hashing here first and
|
|
100
|
+
# handing SubCache an already-hashed value made it hash the
|
|
101
|
+
# digest a second time, landing on a key nothing had ever
|
|
102
|
+
# written to, so logout silently failed to evict the entry.
|
|
103
|
+
token = payload.respond_to?(:session_token) ? payload.session_token : nil
|
|
104
|
+
if token && !token.to_s.empty?
|
|
105
|
+
cache.identity.invalidate(token.to_s)
|
|
106
|
+
else
|
|
107
|
+
bump_subject(cache, subject_id(payload))
|
|
108
|
+
end
|
|
109
|
+
else
|
|
110
|
+
# A `_User` write gives a user id, but identity entries are
|
|
111
|
+
# keyed by session token and no reverse map exists. Bumping a
|
|
112
|
+
# per-user generation invalidates every one of that user's
|
|
113
|
+
# entries in O(1), including tokens this process has never
|
|
114
|
+
# resolved, and without Parse Server's master-key `_Session`
|
|
115
|
+
# query.
|
|
116
|
+
bump_subject(cache, subject_id(payload))
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
true
|
|
120
|
+
end
|
|
121
|
+
[type, class_name]
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
# These triggers fire AFTER the write has committed, so raising here
|
|
126
|
+
# turns an already-successful save into a 500 for the client. A cache
|
|
127
|
+
# backend that is down must degrade to TTL-bounded staleness, never to
|
|
128
|
+
# a failed application request. The error is reported without its
|
|
129
|
+
# message, which can carry a key and therefore a session token.
|
|
130
|
+
def guard
|
|
131
|
+
yield
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
warn "[Parse::Cache::Invalidation] invalidation failed: #{e.class}"
|
|
134
|
+
if defined?(ActiveSupport::Notifications)
|
|
135
|
+
begin
|
|
136
|
+
ActiveSupport::Notifications.instrument(
|
|
137
|
+
"parse.cache.invalidation_error", error: e.class.name,
|
|
138
|
+
)
|
|
139
|
+
rescue StandardError
|
|
140
|
+
nil
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
nil
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def bump_subject(cache, user_id)
|
|
147
|
+
return if user_id.nil? || user_id.to_s.empty?
|
|
148
|
+
cache.identity.bump_generation(user_id.to_s)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The affected user's id. For a `_User` trigger that is the object
|
|
152
|
+
# itself; for a session it is the session's user pointer.
|
|
153
|
+
def subject_id(payload)
|
|
154
|
+
object = payload.respond_to?(:parse_object) ? payload.parse_object : nil
|
|
155
|
+
return nil if object.nil?
|
|
156
|
+
if object.respond_to?(:id) && payload.respond_to?(:parse_class) &&
|
|
157
|
+
payload.parse_class == USER_CLASS
|
|
158
|
+
return object.id
|
|
159
|
+
end
|
|
160
|
+
user = object.respond_to?(:user) ? object.user : nil
|
|
161
|
+
return user.id if user.respond_to?(:id)
|
|
162
|
+
object.respond_to?(:id) ? object.id : nil
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|