parse-stack-next 5.7.5 → 5.8.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 +856 -0
- data/README.md +15 -4
- data/docs/TEST_SERVER.md +2 -2
- data/docs/acl_clp_guide.md +7 -0
- data/docs/atlas_vector_search_guide.md +190 -14
- data/docs/client_sdk_guide.md +11 -0
- data/docs/mcp_guide.md +318 -6
- data/docs/mongodb_direct_guide.md +27 -0
- data/docs/usage_guide.md +38 -0
- data/docs/webhooks_guide.md +74 -17
- data/lib/parse/acl_scope.rb +159 -41
- data/lib/parse/agent/approval_gate.rb +0 -0
- data/lib/parse/agent/constraint_translator.rb +42 -15
- data/lib/parse/agent/describe.rb +3 -1
- 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_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 +93 -1
- 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/protected_paths.rb +236 -0
- data/lib/parse/atlas_search.rb +95 -23
- data/lib/parse/authorization.rb +54 -1
- data/lib/parse/client/batch.rb +231 -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 +201 -38
- data/lib/parse/clp_scope.rb +281 -23
- 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/model/acl.rb +119 -21
- data/lib/parse/model/associations/belongs_to.rb +25 -3
- data/lib/parse/model/associations/collection_proxy.rb +138 -17
- data/lib/parse/model/associations/has_many.rb +38 -9
- data/lib/parse/model/associations/has_one.rb +3 -1
- data/lib/parse/model/associations/pointer_collection_proxy.rb +109 -17
- data/lib/parse/model/associations/relation_collection_proxy.rb +134 -28
- data/lib/parse/model/bytes.rb +13 -5
- data/lib/parse/model/classes/role.rb +72 -0
- data/lib/parse/model/classes/session.rb +43 -0
- data/lib/parse/model/classes/user.rb +78 -3
- data/lib/parse/model/core/actions.rb +269 -67
- 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 +21 -3
- 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 +265 -17
- data/lib/parse/model/phone.rb +54 -5
- data/lib/parse/model/pointer.rb +40 -6
- data/lib/parse/mongodb.rb +170 -60
- data/lib/parse/pipeline_security.rb +415 -26
- data/lib/parse/query/constraint.rb +30 -0
- data/lib/parse/query/constraints.rb +58 -32
- 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 +1100 -134
- data/lib/parse/retrieval/agent_tool.rb +290 -17
- 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 +72 -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 +36 -18
- data/lib/parse/vector_search/index_definition.rb +237 -0
- data/lib/parse/vector_search.rb +46 -17
- data/lib/parse/webhooks/payload.rb +93 -6
- data/lib/parse/webhooks/replay_protection.rb +58 -20
- data/lib/parse/webhooks.rb +412 -40
- metadata +8 -1
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
module Parse
|
|
5
|
+
class Agent
|
|
6
|
+
# Supported MCP deployment patterns, packaged as {MCPRackApp} factories.
|
|
7
|
+
#
|
|
8
|
+
# Two explicit access modes, so a deployment can never drift from one into
|
|
9
|
+
# the other by accident:
|
|
10
|
+
#
|
|
11
|
+
# * {MCPRackApp.user_scoped}: every request carries a Parse session token.
|
|
12
|
+
# The agent acts as that user (ACL/CLP enforced by Parse Server on REST
|
|
13
|
+
# and by the SDK on mongo-direct paths). A missing, blank, invalid,
|
|
14
|
+
# expired, or revoked token is refused with 401 before any agent is
|
|
15
|
+
# built, and there is no fallback to master-key access.
|
|
16
|
+
#
|
|
17
|
+
# * {MCPRackApp.master_analytics}: master authority for analytics and
|
|
18
|
+
# trusted operational tools, narrowed by the agent's configured tools,
|
|
19
|
+
# classes, and fields, and read-only by default. Operator identity is
|
|
20
|
+
# mandatory: without a `principal_resolver` every master-key agent
|
|
21
|
+
# fingerprints as the same principal, and callers would share ownership
|
|
22
|
+
# of each other's streams, approvals, and cancellations.
|
|
23
|
+
#
|
|
24
|
+
# Direct `MCPRackApp.new(agent_factory: ...)` construction is unchanged
|
|
25
|
+
# and remains the way to build anything else, including an intentional
|
|
26
|
+
# single-operator master-key endpoint.
|
|
27
|
+
class MCPRackApp
|
|
28
|
+
# Default seconds between identity re-checks on a user-scoped listening
|
|
29
|
+
# stream. Bounds how long a revoked session keeps receiving
|
|
30
|
+
# notifications on an already-open stream.
|
|
31
|
+
DEFAULT_SESSION_REVALIDATE_INTERVAL = 60
|
|
32
|
+
|
|
33
|
+
# Rack env key where `user_scoped` records the verified user id for the
|
|
34
|
+
# request, read back by its principal resolver.
|
|
35
|
+
USER_PRINCIPAL_ENV_KEY = "parse.agent.user_scoped.user_id"
|
|
36
|
+
|
|
37
|
+
# Raised by {validate_session!} when Parse Server could not be asked
|
|
38
|
+
# (unreachable, 5xx, an unexpected error). Distinct from
|
|
39
|
+
# {Parse::Agent::Unauthorized}, which means the session was rejected.
|
|
40
|
+
class SessionCheckUnavailable < StandardError; end
|
|
41
|
+
|
|
42
|
+
# Accepted values for `session_validation:`.
|
|
43
|
+
SESSION_VALIDATION_MODES = %i[per_request cached].freeze
|
|
44
|
+
|
|
45
|
+
# One rate limiter per principal, shared by every agent a deployment
|
|
46
|
+
# factory builds for that principal. A factory builds a fresh agent per
|
|
47
|
+
# request, and each agent would otherwise get a fresh limiter, so the
|
|
48
|
+
# configured rate limit never accumulated across requests. Bounded
|
|
49
|
+
# (least recently used) so a stream of principals cannot grow it
|
|
50
|
+
# without limit; an evicted principal starts a new window.
|
|
51
|
+
class PrincipalRateLimiters
|
|
52
|
+
DEFAULT_MAX_ENTRIES = 10_000
|
|
53
|
+
|
|
54
|
+
def initialize(limit:, window:, max_entries: DEFAULT_MAX_ENTRIES)
|
|
55
|
+
@limit = limit
|
|
56
|
+
@window = window
|
|
57
|
+
@max = max_entries
|
|
58
|
+
@limiters = {}
|
|
59
|
+
@mutex = Mutex.new
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# @param principal [String]
|
|
63
|
+
# @return [Parse::Agent::RateLimiter]
|
|
64
|
+
def fetch(principal)
|
|
65
|
+
key = principal.to_s
|
|
66
|
+
@mutex.synchronize do
|
|
67
|
+
limiter = @limiters.delete(key) || Parse::Agent::RateLimiter.new(limit: @limit, window: @window)
|
|
68
|
+
@limiters[key] = limiter
|
|
69
|
+
@limiters.shift while @limiters.size > @max
|
|
70
|
+
limiter
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def size
|
|
75
|
+
@mutex.synchronize { @limiters.size }
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# @api private
|
|
80
|
+
# The registry a factory uses, or nil when the caller injected its own
|
|
81
|
+
# `rate_limiter:` (honored as-is, e.g. a shared Redis limiter).
|
|
82
|
+
def self.principal_rate_limiters_for(agent_options)
|
|
83
|
+
# Only a real injected limiter bypasses the registry; an explicit
|
|
84
|
+
# `rate_limiter: nil` would otherwise give every request a fresh one.
|
|
85
|
+
return nil unless agent_options[:rate_limiter].nil?
|
|
86
|
+
PrincipalRateLimiters.new(
|
|
87
|
+
limit: agent_options.fetch(:rate_limit, Parse::Agent::DEFAULT_RATE_LIMIT),
|
|
88
|
+
window: agent_options.fetch(:rate_window, Parse::Agent::DEFAULT_RATE_WINDOW),
|
|
89
|
+
)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Options that would give a user-scoped agent authority beyond its
|
|
93
|
+
# session, refused by {MCPRackApp.user_scoped}.
|
|
94
|
+
USER_SCOPED_REFUSED_AGENT_OPTIONS = %i[master_atlas allow_mutations].freeze
|
|
95
|
+
|
|
96
|
+
# Agent constructor options a factory owns. Passing them through
|
|
97
|
+
# `agent_options:` would let configuration override the identity or
|
|
98
|
+
# authority the factory pins, so they are refused at construction.
|
|
99
|
+
FACTORY_OWNED_AGENT_OPTIONS = %i[
|
|
100
|
+
session_token acl_user acl_role
|
|
101
|
+
impersonate_user impersonation_user impersonate_mint impersonation_mint
|
|
102
|
+
impersonate_label impersonation_label
|
|
103
|
+
tenant_id client permissions permission parent
|
|
104
|
+
].freeze
|
|
105
|
+
|
|
106
|
+
# Rack app options a factory owns.
|
|
107
|
+
FACTORY_OWNED_APP_OPTIONS = %i[
|
|
108
|
+
agent_factory principal_resolver
|
|
109
|
+
listening_stream_revalidator listening_stream_revalidate_interval
|
|
110
|
+
].freeze
|
|
111
|
+
|
|
112
|
+
# Default session-token extraction: `Authorization: Bearer <token>`,
|
|
113
|
+
# else `X-Parse-Session-Token`.
|
|
114
|
+
DEFAULT_SESSION_TOKEN_FROM = lambda do |env|
|
|
115
|
+
auth = env["HTTP_AUTHORIZATION"].to_s.strip
|
|
116
|
+
if (m = auth.match(/\ABearer\s+(\S+)\z/i))
|
|
117
|
+
m[1]
|
|
118
|
+
else
|
|
119
|
+
env["HTTP_X_PARSE_SESSION_TOKEN"]
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
class << self
|
|
124
|
+
# Build an MCP endpoint whose agents act as the calling Parse user.
|
|
125
|
+
#
|
|
126
|
+
# Every request (POST and the GET listening stream) must carry a
|
|
127
|
+
# session token. The token is validated before an agent is built;
|
|
128
|
+
# anything missing, blank, invalid, expired, or revoked gets 401.
|
|
129
|
+
# The resulting agent is `Parse::Agent.new(session_token: token, ...)`,
|
|
130
|
+
# so identity comes from the session and cannot be changed by tool
|
|
131
|
+
# arguments. There is no master-key fallback in this mode.
|
|
132
|
+
#
|
|
133
|
+
# **Revocation.** With the default `session_validation: :per_request`,
|
|
134
|
+
# each request re-checks the token against Parse Server
|
|
135
|
+
# (`GET /users/me`, uncached), so a logged-out, expired, or revoked
|
|
136
|
+
# session is refused on the next request. An already-open listening
|
|
137
|
+
# stream is re-checked every `session_revalidate_interval` seconds and
|
|
138
|
+
# closed (tearing down its subscriptions) when the check fails.
|
|
139
|
+
# `session_validation: :cached` trades that for fewer round trips: the
|
|
140
|
+
# token is resolved through `client.authorization`, so revocation takes
|
|
141
|
+
# effect after the identity-cache TTL unless the
|
|
142
|
+
# `Parse::Cache::Invalidation` hooks evict it first. See the "Deployment
|
|
143
|
+
# patterns" section of the MCP guide for the full interval table.
|
|
144
|
+
#
|
|
145
|
+
# @param session_token_from [#call, nil] `->(env) { token }`. Defaults
|
|
146
|
+
# to {DEFAULT_SESSION_TOKEN_FROM}.
|
|
147
|
+
# @param permissions [Symbol] agent permission tier; `:readonly`
|
|
148
|
+
# (default), `:write`, or `:admin`. Writes still flow only through
|
|
149
|
+
# declared `agent_method`s and the existing env gates.
|
|
150
|
+
# @param client [Parse::Client, nil] the client to validate tokens and
|
|
151
|
+
# build agents against. nil uses `Parse.client` at request time.
|
|
152
|
+
# @param tenant_from [#call, nil] `->(env, user_id) { tenant }` to pin
|
|
153
|
+
# the agent's `tenant_id` server-side. A nil or blank result is
|
|
154
|
+
# refused with 401 (fail closed).
|
|
155
|
+
# @param session_validation [Symbol] `:per_request` (default) or
|
|
156
|
+
# `:cached`. See above.
|
|
157
|
+
# @param session_revalidate_interval [Numeric] seconds between identity
|
|
158
|
+
# re-checks on an open listening stream.
|
|
159
|
+
# @param agent_options [Hash] extra `Parse::Agent.new` options (for
|
|
160
|
+
# example `tools:`, `methods:`, `classes:`, `filters:`). Identity and
|
|
161
|
+
# authority options in {FACTORY_OWNED_AGENT_OPTIONS} are refused.
|
|
162
|
+
# @param rack_options [Hash] remaining `MCPRackApp.new` options
|
|
163
|
+
# (`transport:`, `logger:`, `allowed_origins:`, ...). Options in
|
|
164
|
+
# {FACTORY_OWNED_APP_OPTIONS} are refused.
|
|
165
|
+
# @return [MCPRackApp]
|
|
166
|
+
# @raise [ArgumentError] on a factory-owned option or invalid setting.
|
|
167
|
+
def user_scoped(session_token_from: nil, permissions: :readonly, client: nil,
|
|
168
|
+
tenant_from: nil, session_validation: :per_request,
|
|
169
|
+
session_revalidate_interval: DEFAULT_SESSION_REVALIDATE_INTERVAL,
|
|
170
|
+
agent_options: {}, **rack_options, &block)
|
|
171
|
+
raise ArgumentError, "MCPRackApp.user_scoped builds its own agent factory; do not pass a block" if block
|
|
172
|
+
if permissions.to_s == "admin"
|
|
173
|
+
# The admin tier skips the spend cap and score quantization, which
|
|
174
|
+
# are cost and inference controls meant for untrusted callers. Every
|
|
175
|
+
# signed-in user of this endpoint would inherit that exemption.
|
|
176
|
+
raise ArgumentError,
|
|
177
|
+
"MCPRackApp.user_scoped does not accept permissions: :admin; use :readonly or :write"
|
|
178
|
+
end
|
|
179
|
+
assert_factory_options!(:user_scoped, agent_options, rack_options)
|
|
180
|
+
extractor = session_token_from || DEFAULT_SESSION_TOKEN_FROM
|
|
181
|
+
raise ArgumentError, "session_token_from must respond to #call" unless extractor.respond_to?(:call)
|
|
182
|
+
if tenant_from && !tenant_from.respond_to?(:call)
|
|
183
|
+
raise ArgumentError, "tenant_from must respond to #call"
|
|
184
|
+
end
|
|
185
|
+
unless SESSION_VALIDATION_MODES.include?(session_validation)
|
|
186
|
+
raise ArgumentError,
|
|
187
|
+
"session_validation must be one of #{SESSION_VALIDATION_MODES.inspect} " \
|
|
188
|
+
"(got #{session_validation.inspect})"
|
|
189
|
+
end
|
|
190
|
+
options = agent_options.dup.freeze
|
|
191
|
+
limiters = Parse::Agent::MCPRackApp.principal_rate_limiters_for(options)
|
|
192
|
+
|
|
193
|
+
factory = lambda do |env|
|
|
194
|
+
token = extractor.call(env).to_s.strip
|
|
195
|
+
raise Parse::Agent::Unauthorized.new("Missing session token", reason: :missing_session) if token.empty?
|
|
196
|
+
|
|
197
|
+
parse_client = client || Parse.client
|
|
198
|
+
user_id = begin
|
|
199
|
+
validate_session!(parse_client, token, mode: session_validation)
|
|
200
|
+
rescue SessionCheckUnavailable
|
|
201
|
+
# Fail closed for the request, but keep the token cached: the
|
|
202
|
+
# session may well be valid once Parse Server answers again.
|
|
203
|
+
raise Parse::Agent::Unauthorized.new("Session could not be verified",
|
|
204
|
+
reason: :session_check_unavailable)
|
|
205
|
+
end
|
|
206
|
+
# The verified user id is the session's principal, so a refreshed
|
|
207
|
+
# token (or a second login) for the same user keeps its sessions,
|
|
208
|
+
# and per-principal bounds apply per user rather than per token.
|
|
209
|
+
env[USER_PRINCIPAL_ENV_KEY] = user_id
|
|
210
|
+
kwargs = options.merge(session_token: token, permissions: permissions)
|
|
211
|
+
kwargs[:client] = client if client
|
|
212
|
+
kwargs[:rate_limiter] = limiters.fetch("user:#{user_id}") if limiters
|
|
213
|
+
if tenant_from
|
|
214
|
+
tenant = tenant_from.call(env, user_id)
|
|
215
|
+
if tenant.nil? || tenant.to_s.strip.empty?
|
|
216
|
+
raise Parse::Agent::Unauthorized.new("No tenant for session", reason: :missing_tenant)
|
|
217
|
+
end
|
|
218
|
+
kwargs[:tenant_id] = tenant
|
|
219
|
+
end
|
|
220
|
+
Parse::Agent.new(**kwargs)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# A session Parse Server reports invalid closes the stream at once.
|
|
224
|
+
# SessionCheckUnavailable (Parse Server unreachable, a 5xx) is left
|
|
225
|
+
# to propagate, so the stream's revalidation loop counts it as a
|
|
226
|
+
# transient error and only closes after repeated failures.
|
|
227
|
+
revalidator = lambda do |agent|
|
|
228
|
+
token = agent.respond_to?(:session_token) ? agent.session_token.to_s : ""
|
|
229
|
+
return false if token.empty?
|
|
230
|
+
validate_session!(client || Parse.client, token, mode: :per_request)
|
|
231
|
+
true
|
|
232
|
+
rescue Parse::Agent::Unauthorized
|
|
233
|
+
false
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
resolver = ->(_agent, env) { (uid = env[USER_PRINCIPAL_ENV_KEY]) ? "user:#{uid}" : nil }
|
|
237
|
+
|
|
238
|
+
new(agent_factory: factory,
|
|
239
|
+
principal_resolver: resolver,
|
|
240
|
+
listening_stream_revalidator: revalidator,
|
|
241
|
+
listening_stream_revalidate_interval: session_revalidate_interval,
|
|
242
|
+
**rack_options)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Build an MCP endpoint whose agents use master authority, for
|
|
246
|
+
# analytics and trusted operational tools.
|
|
247
|
+
#
|
|
248
|
+
# `principal_resolver:` is required and must identify the operator
|
|
249
|
+
# behind each request (`->(agent, env) { "op:#{verified_id}" }`). It
|
|
250
|
+
# is used for session ownership (streams, approvals, cancellations)
|
|
251
|
+
# and audit; a request it cannot resolve (nil or blank) is refused
|
|
252
|
+
# with 401. Master authority governs what data the agent may reach;
|
|
253
|
+
# the agent's configured `tools:`, `classes:`, `filters:`, and field
|
|
254
|
+
# policy govern what it may actually do. Read-only by default.
|
|
255
|
+
#
|
|
256
|
+
# @param principal_resolver [#call] `->(agent, env) { principal }`.
|
|
257
|
+
# Required.
|
|
258
|
+
# @param permissions [Symbol] defaults to `:readonly`.
|
|
259
|
+
# @param client [Parse::Client, nil] a client configured with the
|
|
260
|
+
# master key. nil uses `Parse.client` at request time.
|
|
261
|
+
# @param tenant_from [#call, nil] `->(env, principal) { tenant }` to pin
|
|
262
|
+
# `tenant_id`; nil or blank is refused with 401.
|
|
263
|
+
# @param agent_options [Hash] extra `Parse::Agent.new` options.
|
|
264
|
+
# Identity and authority options are refused.
|
|
265
|
+
# @param rack_options [Hash] remaining `MCPRackApp.new` options.
|
|
266
|
+
# @return [MCPRackApp]
|
|
267
|
+
# @raise [ArgumentError] without a callable `principal_resolver`, or on
|
|
268
|
+
# a factory-owned option.
|
|
269
|
+
def master_analytics(principal_resolver:, permissions: :readonly, client: nil,
|
|
270
|
+
tenant_from: nil, agent_options: {}, **rack_options, &block)
|
|
271
|
+
raise ArgumentError, "MCPRackApp.master_analytics builds its own agent factory; do not pass a block" if block
|
|
272
|
+
unless principal_resolver.respond_to?(:call)
|
|
273
|
+
raise ArgumentError,
|
|
274
|
+
"MCPRackApp.master_analytics requires principal_resolver: (->(agent, env) { operator_id }). " \
|
|
275
|
+
"Without it every master-key agent shares one principal, so callers could attach to, " \
|
|
276
|
+
"approve, or cancel each other's sessions. For a deliberate single-operator endpoint, " \
|
|
277
|
+
"build MCPRackApp.new(agent_factory: ...) directly."
|
|
278
|
+
end
|
|
279
|
+
if tenant_from && !tenant_from.respond_to?(:call)
|
|
280
|
+
raise ArgumentError, "tenant_from must respond to #call"
|
|
281
|
+
end
|
|
282
|
+
assert_factory_options!(:master_analytics, agent_options, rack_options)
|
|
283
|
+
options = agent_options.dup.freeze
|
|
284
|
+
limiters = Parse::Agent::MCPRackApp.principal_rate_limiters_for(options)
|
|
285
|
+
|
|
286
|
+
# Resolve once per request and reuse it for ownership, so the
|
|
287
|
+
# resolver's identity check is not repeated (and cannot disagree
|
|
288
|
+
# with itself) between the factory and the fingerprint.
|
|
289
|
+
memo_key = "parse.mcp.analytics_principal"
|
|
290
|
+
resolver = lambda do |agent, env|
|
|
291
|
+
env.key?(memo_key) ? env[memo_key] : (env[memo_key] = principal_resolver.call(agent, env))
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
factory = lambda do |env|
|
|
295
|
+
parse_client = client || Parse.client
|
|
296
|
+
unless parse_client.respond_to?(:master_key) && !parse_client.master_key.to_s.empty?
|
|
297
|
+
raise Parse::Agent::Unauthorized.new("master_analytics requires a master-key client",
|
|
298
|
+
reason: :no_master_key)
|
|
299
|
+
end
|
|
300
|
+
kwargs = options.merge(permissions: permissions)
|
|
301
|
+
kwargs[:client] = client if client
|
|
302
|
+
agent = Parse::Agent.new(**kwargs)
|
|
303
|
+
principal = resolver.call(agent, env)
|
|
304
|
+
if principal.nil? || principal.to_s.strip.empty?
|
|
305
|
+
raise Parse::Agent::Unauthorized.new("Unidentified operator", reason: :missing_principal)
|
|
306
|
+
end
|
|
307
|
+
# The operator is known only once an agent exists (the resolver
|
|
308
|
+
# receives it), so rebuild this request's agent with the
|
|
309
|
+
# operator's shared limiter.
|
|
310
|
+
agent = Parse::Agent.new(**kwargs, rate_limiter: limiters.fetch("op:#{principal}")) if limiters
|
|
311
|
+
if tenant_from
|
|
312
|
+
tenant = tenant_from.call(env, principal)
|
|
313
|
+
if tenant.nil? || tenant.to_s.strip.empty?
|
|
314
|
+
raise Parse::Agent::Unauthorized.new("No tenant for operator", reason: :missing_tenant)
|
|
315
|
+
end
|
|
316
|
+
agent.tenant_id = tenant
|
|
317
|
+
end
|
|
318
|
+
agent
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
new(agent_factory: factory, principal_resolver: resolver, **rack_options)
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
# Validate a session token and return its user id, or raise
|
|
325
|
+
# {Parse::Agent::Unauthorized}.
|
|
326
|
+
#
|
|
327
|
+
# `:per_request` asks Parse Server directly (`GET /users/me`, response
|
|
328
|
+
# cache bypassed) and, on failure, evicts the token from the client's
|
|
329
|
+
# authorization identity cache so mongo-direct paths stop trusting it
|
|
330
|
+
# too. `:cached` resolves through `client.authorization` (identity
|
|
331
|
+
# cache, then `/users/me` on a miss).
|
|
332
|
+
#
|
|
333
|
+
# @api private
|
|
334
|
+
def validate_session!(parse_client, token, mode:)
|
|
335
|
+
if mode == :cached
|
|
336
|
+
begin
|
|
337
|
+
resolved = parse_client.authorization.resolve(token)
|
|
338
|
+
rescue Parse::Authorization::InvalidSession
|
|
339
|
+
raise Parse::Agent::Unauthorized.new("Invalid session", reason: :invalid_session)
|
|
340
|
+
end
|
|
341
|
+
user_id = resolved.respond_to?(:user_id) ? resolved.user_id : nil
|
|
342
|
+
raise Parse::Agent::Unauthorized.new("Invalid session", reason: :invalid_session) if user_id.to_s.empty?
|
|
343
|
+
return user_id.to_s
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
response = begin
|
|
347
|
+
parse_client.current_user(token, cache: false)
|
|
348
|
+
rescue Parse::Error::InvalidSessionTokenError
|
|
349
|
+
# Parse Server 9.x answers `/users/me` for a revoked, expired or
|
|
350
|
+
# unknown token with HTTP 400 and code 209, which the client
|
|
351
|
+
# raises as InvalidSessionTokenError. That is a definitive
|
|
352
|
+
# rejection of the session, not an outage: evict the token and
|
|
353
|
+
# refuse it. Treating it as an outage left a logged-out token
|
|
354
|
+
# in the identity cache and answered the caller with a retryable
|
|
355
|
+
# error instead of a 401.
|
|
356
|
+
evict_session!(parse_client, token)
|
|
357
|
+
raise Parse::Agent::Unauthorized.new("Invalid session", reason: :invalid_session)
|
|
358
|
+
rescue StandardError => e
|
|
359
|
+
raise SessionCheckUnavailable, "session check failed: #{e.class}"
|
|
360
|
+
end
|
|
361
|
+
if response.nil?
|
|
362
|
+
raise SessionCheckUnavailable, "session check returned no response"
|
|
363
|
+
end
|
|
364
|
+
if response.error? && !session_rejected?(response)
|
|
365
|
+
# Parse Server could not answer (5xx, timeout, an unexpected
|
|
366
|
+
# error code). That says nothing about the session, so it is
|
|
367
|
+
# neither accepted nor evicted from the identity cache.
|
|
368
|
+
raise SessionCheckUnavailable, "session check failed (#{response.http_status || response.code})"
|
|
369
|
+
end
|
|
370
|
+
result = response.error? ? nil : response.result
|
|
371
|
+
user_id = result.is_a?(Hash) ? (result["objectId"] || result[:objectId]) : nil
|
|
372
|
+
if user_id.to_s.empty?
|
|
373
|
+
evict_session!(parse_client, token)
|
|
374
|
+
raise Parse::Agent::Unauthorized.new("Invalid session", reason: :invalid_session)
|
|
375
|
+
end
|
|
376
|
+
user_id.to_s
|
|
377
|
+
end
|
|
378
|
+
|
|
379
|
+
private
|
|
380
|
+
|
|
381
|
+
# Forget a rejected token in the client's identity plane so
|
|
382
|
+
# mongo-direct paths stop trusting it too.
|
|
383
|
+
def evict_session!(parse_client, token)
|
|
384
|
+
if parse_client.respond_to?(:authorization) && parse_client.authorization.respond_to?(:invalidate)
|
|
385
|
+
parse_client.authorization.invalidate(token)
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
|
|
389
|
+
# True when Parse Server rejected the session itself (invalid or
|
|
390
|
+
# expired token, or an auth failure), as opposed to failing to answer.
|
|
391
|
+
def session_rejected?(response)
|
|
392
|
+
return true if response.respond_to?(:permission_denied?) && response.permission_denied?
|
|
393
|
+
code = response.respond_to?(:code) ? response.code : nil
|
|
394
|
+
code == Parse::Response::ERROR_INVALID_SESSION_TOKEN || code == 101
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def assert_factory_options!(factory_name, agent_options, rack_options)
|
|
398
|
+
unless agent_options.is_a?(Hash)
|
|
399
|
+
raise ArgumentError, "#{factory_name}: agent_options must be a Hash"
|
|
400
|
+
end
|
|
401
|
+
owned = agent_options.keys.map(&:to_sym) & FACTORY_OWNED_AGENT_OPTIONS
|
|
402
|
+
unless owned.empty?
|
|
403
|
+
raise ArgumentError,
|
|
404
|
+
"#{factory_name}: agent_options may not set #{owned.inspect}; the factory owns " \
|
|
405
|
+
"identity and authority (use the factory's own keyword where one exists)"
|
|
406
|
+
end
|
|
407
|
+
if factory_name == :user_scoped
|
|
408
|
+
# A signed-in user's agent must not be handed authority beyond
|
|
409
|
+
# its session: master Atlas reach (unscoped $searchMeta counts)
|
|
410
|
+
# or the mutation override.
|
|
411
|
+
elevated = agent_options.keys.map(&:to_sym) & USER_SCOPED_REFUSED_AGENT_OPTIONS
|
|
412
|
+
unless elevated.empty?
|
|
413
|
+
raise ArgumentError,
|
|
414
|
+
"user_scoped: agent_options may not set #{elevated.inspect}; a user-scoped " \
|
|
415
|
+
"agent never receives authority beyond its session"
|
|
416
|
+
end
|
|
417
|
+
end
|
|
418
|
+
owned_app = rack_options.keys.map(&:to_sym) & FACTORY_OWNED_APP_OPTIONS
|
|
419
|
+
unless owned_app.empty?
|
|
420
|
+
raise ArgumentError, "#{factory_name}: #{owned_app.inspect} are set by the factory"
|
|
421
|
+
end
|
|
422
|
+
end
|
|
423
|
+
end
|
|
424
|
+
end
|
|
425
|
+
end
|
|
426
|
+
end
|