parse-stack-next 4.5.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 +7 -0
- data/.bundle/config +2 -0
- data/.env.sample +112 -0
- data/.env.test +10 -0
- data/.github/workflows/ruby.yml +36 -0
- data/.gitignore +49 -0
- data/.ruby-version +1 -0
- data/.solargraph.yml +22 -0
- data/CHANGELOG.md +5816 -0
- data/Gemfile +30 -0
- data/Gemfile.lock +175 -0
- data/LICENSE.txt +23 -0
- data/Makefile +63 -0
- data/README.md +5655 -0
- data/Rakefile +573 -0
- data/bin/console +38 -0
- data/bin/parse-console +136 -0
- data/bin/server +17 -0
- data/bin/setup +7 -0
- data/config/parse-config.json +12 -0
- data/docs/TEST_SERVER.md +271 -0
- data/docs/_config.yml +1 -0
- data/docs/mcp_guide.md +3484 -0
- data/docs/mongodb_direct_guide.md +1348 -0
- data/docs/mongodb_index_optimization_guide.md +631 -0
- data/examples/transaction_example.rb +219 -0
- data/lib/parse/acl_scope.rb +728 -0
- data/lib/parse/agent/cancellation_token.rb +80 -0
- data/lib/parse/agent/constraint_translator.rb +480 -0
- data/lib/parse/agent/describe.rb +420 -0
- data/lib/parse/agent/errors.rb +133 -0
- data/lib/parse/agent/mcp_client.rb +557 -0
- data/lib/parse/agent/mcp_dispatcher.rb +1023 -0
- data/lib/parse/agent/mcp_rack_app.rb +1143 -0
- data/lib/parse/agent/mcp_server.rb +376 -0
- data/lib/parse/agent/metadata_audit.rb +259 -0
- data/lib/parse/agent/metadata_dsl.rb +733 -0
- data/lib/parse/agent/metadata_registry.rb +794 -0
- data/lib/parse/agent/pipeline_validator.rb +82 -0
- data/lib/parse/agent/prompts.rb +351 -0
- data/lib/parse/agent/rate_limiter.rb +158 -0
- data/lib/parse/agent/relation_graph.rb +162 -0
- data/lib/parse/agent/result_formatter.rb +453 -0
- data/lib/parse/agent/tools.rb +5489 -0
- data/lib/parse/agent.rb +3249 -0
- data/lib/parse/api/aggregate.rb +79 -0
- data/lib/parse/api/all.rb +26 -0
- data/lib/parse/api/analytics.rb +18 -0
- data/lib/parse/api/batch.rb +33 -0
- data/lib/parse/api/cloud_functions.rb +58 -0
- data/lib/parse/api/config.rb +125 -0
- data/lib/parse/api/files.rb +29 -0
- data/lib/parse/api/hooks.rb +117 -0
- data/lib/parse/api/objects.rb +146 -0
- data/lib/parse/api/path_segment.rb +75 -0
- data/lib/parse/api/push.rb +20 -0
- data/lib/parse/api/schema.rb +49 -0
- data/lib/parse/api/server.rb +50 -0
- data/lib/parse/api/sessions.rb +24 -0
- data/lib/parse/api/users.rb +250 -0
- data/lib/parse/atlas_search/index_manager.rb +353 -0
- data/lib/parse/atlas_search/result.rb +204 -0
- data/lib/parse/atlas_search/search_builder.rb +604 -0
- data/lib/parse/atlas_search/session.rb +253 -0
- data/lib/parse/atlas_search.rb +995 -0
- data/lib/parse/client/authentication.rb +97 -0
- data/lib/parse/client/batch.rb +234 -0
- data/lib/parse/client/body_builder.rb +240 -0
- data/lib/parse/client/caching.rb +203 -0
- data/lib/parse/client/logging.rb +293 -0
- data/lib/parse/client/profiling.rb +181 -0
- data/lib/parse/client/protocol.rb +91 -0
- data/lib/parse/client/request.rb +233 -0
- data/lib/parse/client/response.rb +208 -0
- data/lib/parse/client.rb +1104 -0
- data/lib/parse/clp_scope.rb +361 -0
- data/lib/parse/live_query/circuit_breaker.rb +256 -0
- data/lib/parse/live_query/client.rb +1001 -0
- data/lib/parse/live_query/configuration.rb +224 -0
- data/lib/parse/live_query/event.rb +115 -0
- data/lib/parse/live_query/event_queue.rb +272 -0
- data/lib/parse/live_query/health_monitor.rb +214 -0
- data/lib/parse/live_query/logging.rb +149 -0
- data/lib/parse/live_query/subscription.rb +294 -0
- data/lib/parse/live_query.rb +163 -0
- data/lib/parse/lookup_rewriter.rb +445 -0
- data/lib/parse/model/acl.rb +968 -0
- data/lib/parse/model/associations/belongs_to.rb +275 -0
- data/lib/parse/model/associations/collection_proxy.rb +435 -0
- data/lib/parse/model/associations/has_many.rb +597 -0
- data/lib/parse/model/associations/has_one.rb +158 -0
- data/lib/parse/model/associations/pointer_collection_proxy.rb +134 -0
- data/lib/parse/model/associations/relation_collection_proxy.rb +177 -0
- data/lib/parse/model/bytes.rb +62 -0
- data/lib/parse/model/classes/audience.rb +262 -0
- data/lib/parse/model/classes/installation.rb +363 -0
- data/lib/parse/model/classes/job_schedule.rb +153 -0
- data/lib/parse/model/classes/job_status.rb +264 -0
- data/lib/parse/model/classes/product.rb +75 -0
- data/lib/parse/model/classes/push_status.rb +263 -0
- data/lib/parse/model/classes/role.rb +751 -0
- data/lib/parse/model/classes/session.rb +201 -0
- data/lib/parse/model/classes/user.rb +943 -0
- data/lib/parse/model/clp.rb +544 -0
- data/lib/parse/model/core/actions.rb +1268 -0
- data/lib/parse/model/core/builder.rb +139 -0
- data/lib/parse/model/core/create_lock.rb +386 -0
- data/lib/parse/model/core/describe.rb +382 -0
- data/lib/parse/model/core/enhanced_change_tracking.rb +159 -0
- data/lib/parse/model/core/errors.rb +38 -0
- data/lib/parse/model/core/fetching.rb +566 -0
- data/lib/parse/model/core/field_guards.rb +220 -0
- data/lib/parse/model/core/indexing.rb +382 -0
- data/lib/parse/model/core/parse_reference.rb +407 -0
- data/lib/parse/model/core/properties.rb +809 -0
- data/lib/parse/model/core/querying.rb +491 -0
- data/lib/parse/model/core/schema.rb +202 -0
- data/lib/parse/model/core/search_indexing.rb +174 -0
- data/lib/parse/model/date.rb +88 -0
- data/lib/parse/model/email.rb +213 -0
- data/lib/parse/model/file.rb +527 -0
- data/lib/parse/model/geojson.rb +271 -0
- data/lib/parse/model/geopoint.rb +261 -0
- data/lib/parse/model/model.rb +260 -0
- data/lib/parse/model/object.rb +2068 -0
- data/lib/parse/model/phone.rb +520 -0
- data/lib/parse/model/pointer.rb +443 -0
- data/lib/parse/model/polygon.rb +406 -0
- data/lib/parse/model/push.rb +975 -0
- data/lib/parse/model/shortnames.rb +8 -0
- data/lib/parse/model/time_zone.rb +141 -0
- data/lib/parse/model/validations/uniqueness_validator.rb +97 -0
- data/lib/parse/model/validations.rb +96 -0
- data/lib/parse/mongodb.rb +2300 -0
- data/lib/parse/pipeline_security.rb +554 -0
- data/lib/parse/query/constraint.rb +198 -0
- data/lib/parse/query/constraints.rb +3279 -0
- data/lib/parse/query/cursor.rb +434 -0
- data/lib/parse/query/n_plus_one_detector.rb +445 -0
- data/lib/parse/query/operation.rb +104 -0
- data/lib/parse/query/ordering.rb +66 -0
- data/lib/parse/query.rb +7028 -0
- data/lib/parse/schema/index_migrator.rb +291 -0
- data/lib/parse/schema/search_index_migrator.rb +289 -0
- data/lib/parse/schema.rb +494 -0
- data/lib/parse/stack/generators/rails.rb +40 -0
- data/lib/parse/stack/generators/templates/model.erb +51 -0
- data/lib/parse/stack/generators/templates/model_installation.rb +4 -0
- data/lib/parse/stack/generators/templates/model_role.rb +4 -0
- data/lib/parse/stack/generators/templates/model_session.rb +4 -0
- data/lib/parse/stack/generators/templates/model_user.rb +11 -0
- data/lib/parse/stack/generators/templates/parse.rb +12 -0
- data/lib/parse/stack/generators/templates/webhooks.rb +10 -0
- data/lib/parse/stack/railtie.rb +18 -0
- data/lib/parse/stack/tasks.rb +563 -0
- data/lib/parse/stack/version.rb +11 -0
- data/lib/parse/stack.rb +455 -0
- data/lib/parse/two_factor_auth/user_extension.rb +449 -0
- data/lib/parse/two_factor_auth.rb +310 -0
- data/lib/parse/webhooks/payload.rb +360 -0
- data/lib/parse/webhooks/registration.rb +199 -0
- data/lib/parse/webhooks/replay_protection.rb +189 -0
- data/lib/parse/webhooks.rb +510 -0
- data/lib/parse-stack-next.rb +5 -0
- data/lib/parse-stack.rb +5 -0
- data/parse-stack-next.gemspec +82 -0
- data/parse-stack.png +0 -0
- data/scripts/debug-ips.js +35 -0
- data/scripts/docker/Dockerfile.parse +13 -0
- data/scripts/docker/atlas-init.js +284 -0
- data/scripts/docker/docker-compose.atlas.yml +76 -0
- data/scripts/docker/docker-compose.test.yml +106 -0
- data/scripts/docker/mongo-init.js +21 -0
- data/scripts/eval_mcp_with_lm_studio.rb +274 -0
- data/scripts/start-parse.sh +90 -0
- data/scripts/start_mcp_server.rb +78 -0
- data/scripts/test_server_connection.rb +82 -0
- metadata +377 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "set"
|
|
5
|
+
require_relative "model/clp"
|
|
6
|
+
|
|
7
|
+
module Parse
|
|
8
|
+
module CLPScope
|
|
9
|
+
class Denied < StandardError
|
|
10
|
+
attr_reader :class_name, :operation
|
|
11
|
+
|
|
12
|
+
def initialize(class_name, operation, reason = nil)
|
|
13
|
+
@class_name = class_name
|
|
14
|
+
@operation = operation
|
|
15
|
+
super(reason || "CLP denied: #{operation} on #{class_name}")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
OPERATIONS = %i[find count get create update delete].freeze
|
|
20
|
+
|
|
21
|
+
EMPTY_SET = Set.new.freeze
|
|
22
|
+
private_constant :EMPTY_SET
|
|
23
|
+
|
|
24
|
+
# Cache-entry shape. `kind:` is the disposition of the most recent
|
|
25
|
+
# schema-fetch attempt:
|
|
26
|
+
#
|
|
27
|
+
# - `:cached_clp` — schema fetch succeeded and returned a non-empty
|
|
28
|
+
# `classLevelPermissions` map. {permits?} evaluates against it.
|
|
29
|
+
# - `:no_clp` — schema fetch succeeded but the class has no CLP
|
|
30
|
+
# configured (or it's an empty hash). Parse Server treats this as
|
|
31
|
+
# public-default, so {permits?} returns true for all operations.
|
|
32
|
+
# - `:unresolvable` — schema fetch FAILED (network error, 5xx,
|
|
33
|
+
# unexpected exception, missing client). FAIL CLOSED:
|
|
34
|
+
# {permits?} returns false for every non-master query. Without
|
|
35
|
+
# this, a transient schema-endpoint outage would silently turn an
|
|
36
|
+
# admin-only class into public-readable for the duration of the
|
|
37
|
+
# outage — every mongo-direct caller was getting unfiltered rows
|
|
38
|
+
# because `permits?` returned true on `entry.nil?`.
|
|
39
|
+
#
|
|
40
|
+
# `fetched_at` is a monotonic clock reading used by {stale?} so
|
|
41
|
+
# the SDK isn't fooled by NTP adjustments.
|
|
42
|
+
#
|
|
43
|
+
# `clp` is `nil` for `:no_clp` and `:unresolvable`; readers must
|
|
44
|
+
# branch on `kind` before dereferencing.
|
|
45
|
+
CacheEntry = Struct.new(:kind, :clp, :fetched_at, keyword_init: true)
|
|
46
|
+
|
|
47
|
+
# Positive-cache TTL (seconds): how long a successful schema fetch
|
|
48
|
+
# is reused. Mirrors the previous module-level `@cache_ttl` knob;
|
|
49
|
+
# kept identical to preserve backwards-compatible cache behavior.
|
|
50
|
+
POSITIVE_TTL = 3600
|
|
51
|
+
|
|
52
|
+
# Negative-cache TTL (seconds): how long we remember that a class's
|
|
53
|
+
# schema was unresolvable. Short so a transient network blip doesn't
|
|
54
|
+
# gridlock the application for an hour, but non-zero so a permanent
|
|
55
|
+
# failure (auth credential rotated, schema endpoint disabled)
|
|
56
|
+
# doesn't melt the schema endpoint with a thundering herd of retries
|
|
57
|
+
# at request rate.
|
|
58
|
+
NEGATIVE_TTL = 5
|
|
59
|
+
|
|
60
|
+
@cache = {}
|
|
61
|
+
@cache_mutex = Mutex.new
|
|
62
|
+
@cache_ttl = POSITIVE_TTL
|
|
63
|
+
|
|
64
|
+
class << self
|
|
65
|
+
attr_accessor :cache_ttl, :schema_client
|
|
66
|
+
|
|
67
|
+
def permits?(class_name, op, permission_strings)
|
|
68
|
+
return true if permission_strings.nil? # master-key bypass
|
|
69
|
+
return true unless OPERATIONS.include?(op)
|
|
70
|
+
|
|
71
|
+
entry = fetch(class_name)
|
|
72
|
+
# `fetch` never returns nil now — it returns an `:unresolvable`
|
|
73
|
+
# CacheEntry on failure so callers must branch on `kind`.
|
|
74
|
+
case entry.kind
|
|
75
|
+
when :unresolvable
|
|
76
|
+
# FAIL CLOSED. The SDK is the only enforcement layer on the
|
|
77
|
+
# mongo-direct path; without a verified CLP we can't tell
|
|
78
|
+
# whether the class is public or admin-only, and the safe
|
|
79
|
+
# default is to refuse rather than silently surrender row
|
|
80
|
+
# filtering. Operators who want a different posture can
|
|
81
|
+
# pre-populate the cache via {.__cache_put} from a startup
|
|
82
|
+
# hook or static config.
|
|
83
|
+
warn_unresolvable_once!(class_name)
|
|
84
|
+
return false
|
|
85
|
+
when :no_clp
|
|
86
|
+
# Schema fetch succeeded; class has no CLP configured.
|
|
87
|
+
# Parse Server's default is public, so permit.
|
|
88
|
+
return true
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
op_map = entry.clp[op.to_s] || entry.clp[op]
|
|
92
|
+
# nil op_map: the operation has no CLP entry. Parse Server's
|
|
93
|
+
# default is public, so permit.
|
|
94
|
+
return true if op_map.nil?
|
|
95
|
+
# Empty op_map (`delete: {}` etc.): nobody but master-key.
|
|
96
|
+
# Master-key already short-circuited above, so deny here.
|
|
97
|
+
return false if op_map.is_a?(Hash) && op_map.empty?
|
|
98
|
+
|
|
99
|
+
claim_set = permission_strings.is_a?(Set) ? permission_strings : permission_strings.to_set
|
|
100
|
+
|
|
101
|
+
op_map.each do |principal, allowed|
|
|
102
|
+
case principal.to_s
|
|
103
|
+
when "*"
|
|
104
|
+
return true if allowed == true
|
|
105
|
+
when "requiresAuthentication"
|
|
106
|
+
return true if allowed == true && claim_set.any? { |e| user_identity?(e) }
|
|
107
|
+
when "pointerFields"
|
|
108
|
+
# Value is an Array of pointer field names, not a boolean.
|
|
109
|
+
# At the boundary, permit iff the claim set has a user
|
|
110
|
+
# identity to satisfy the constraint with; the actual
|
|
111
|
+
# row-by-row check runs post-fetch via {.pointer_fields_for}.
|
|
112
|
+
next if allowed.nil? || (allowed.respond_to?(:empty?) && allowed.empty?)
|
|
113
|
+
return true if claim_set.any? { |e| user_identity?(e) }
|
|
114
|
+
else
|
|
115
|
+
# Bare userObjectId or "role:Name" — claim-set match.
|
|
116
|
+
return true if allowed == true && claim_set.include?(principal.to_s)
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def assert_permitted!(class_name, op, permission_strings)
|
|
124
|
+
return if permits?(class_name, op, permission_strings)
|
|
125
|
+
raise Denied.new(class_name, op,
|
|
126
|
+
"CLP refuses #{op} on '#{class_name}' for the current scope.")
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def pointer_fields_for(class_name, op)
|
|
130
|
+
entry = fetch(class_name)
|
|
131
|
+
# No CLP at all, or schema unresolvable: there's no
|
|
132
|
+
# pointerFields constraint to apply. (For :unresolvable the
|
|
133
|
+
# caller's `permits?` already failed closed; this helper just
|
|
134
|
+
# returns nil so a post-fetch row-filter step is skipped.)
|
|
135
|
+
return nil if entry.kind == :no_clp || entry.kind == :unresolvable
|
|
136
|
+
op_map = entry.clp[op.to_s] || entry.clp[op]
|
|
137
|
+
return nil unless op_map.is_a?(Hash)
|
|
138
|
+
fields = op_map["pointerFields"] || op_map[:pointerFields]
|
|
139
|
+
return nil if fields.nil?
|
|
140
|
+
arr = Array(fields).map(&:to_s)
|
|
141
|
+
arr.empty? ? nil : arr
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def protected_fields_for(class_name, permission_strings)
|
|
145
|
+
return EMPTY_SET if permission_strings.nil?
|
|
146
|
+
|
|
147
|
+
entry = fetch(class_name)
|
|
148
|
+
# No CLP / unresolvable: nothing to strip. For :unresolvable,
|
|
149
|
+
# `permits?` already refused the query, so this branch is only
|
|
150
|
+
# reached when callers ask for the protected-fields set directly
|
|
151
|
+
# (e.g. for documentation or audit tooling).
|
|
152
|
+
return EMPTY_SET if entry.kind == :no_clp || entry.kind == :unresolvable
|
|
153
|
+
protected_map = entry.clp["protectedFields"] || entry.clp[:protectedFields]
|
|
154
|
+
return EMPTY_SET if protected_map.nil? || protected_map.empty?
|
|
155
|
+
|
|
156
|
+
strip = Set.new(Array(protected_map["*"] || protected_map[:"*"]).map(&:to_s))
|
|
157
|
+
|
|
158
|
+
claim_set = permission_strings.is_a?(Set) ? permission_strings : permission_strings.to_set
|
|
159
|
+
claim_set.each do |claim|
|
|
160
|
+
next if claim == "*"
|
|
161
|
+
override = protected_map[claim.to_s] || protected_map[claim.to_sym]
|
|
162
|
+
next if override.nil?
|
|
163
|
+
override_set = Set.new(Array(override).map(&:to_s))
|
|
164
|
+
strip &= override_set
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
strip.freeze
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def redact_protected_fields!(documents, strip_set)
|
|
171
|
+
return documents if documents.nil? || documents.empty?
|
|
172
|
+
return documents if strip_set.nil? || strip_set.empty?
|
|
173
|
+
documents.each { |doc| walk_and_delete!(doc, strip_set) }
|
|
174
|
+
documents
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def filter_by_pointer_fields(documents, pointer_fields, user_id)
|
|
178
|
+
return documents if pointer_fields.nil? || pointer_fields.empty?
|
|
179
|
+
return [] if user_id.nil? || user_id.to_s.empty?
|
|
180
|
+
documents.select { |doc| any_pointer_matches?(doc, pointer_fields, user_id.to_s) }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def invalidate!(class_name)
|
|
184
|
+
@cache_mutex.synchronize { @cache.delete(class_name.to_s) }
|
|
185
|
+
nil
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def reset_cache!
|
|
189
|
+
@cache_mutex.synchronize { @cache.clear }
|
|
190
|
+
# Also drop the unresolvable-class warned-once registry so
|
|
191
|
+
# tests that assert on `warn` emission for a class don't get
|
|
192
|
+
# silenced by an earlier test's call.
|
|
193
|
+
@warned_unresolvable_classes = Set.new
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def cache_stats
|
|
198
|
+
@cache_mutex.synchronize do
|
|
199
|
+
{ size: @cache.size, class_names: @cache.keys.sort }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Test/operator-facing hook: pre-populate the cache with a known
|
|
204
|
+
# CLP for `class_name`. An empty/nil `clp` is recorded as
|
|
205
|
+
# `:no_clp` (matches the public-default semantics Parse Server
|
|
206
|
+
# exposes when no CLP is configured); a non-empty `clp` is
|
|
207
|
+
# recorded as `:cached_clp` (the standard happy path).
|
|
208
|
+
def __cache_put(class_name, clp:)
|
|
209
|
+
normalized = clp || {}
|
|
210
|
+
kind = normalized.empty? ? :no_clp : :cached_clp
|
|
211
|
+
entry = CacheEntry.new(kind: kind, clp: normalized, fetched_at: monotonic_now)
|
|
212
|
+
@cache_mutex.synchronize { @cache[class_name.to_s] = entry }
|
|
213
|
+
entry
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Reset the unresolvable-class one-shot warning registry.
|
|
217
|
+
# Test-only — prevents warned-once state from leaking across
|
|
218
|
+
# the suite and silencing assertions on warning emission.
|
|
219
|
+
# @!visibility private
|
|
220
|
+
def reset_warning_state!
|
|
221
|
+
@warned_unresolvable_classes = Set.new
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
private
|
|
225
|
+
|
|
226
|
+
# Always returns a {CacheEntry}. On schema-fetch failure (network
|
|
227
|
+
# error, unsuccessful response, raised exception, missing client)
|
|
228
|
+
# the entry has `kind: :unresolvable` and is held for
|
|
229
|
+
# {NEGATIVE_TTL} seconds to suppress request-rate retries against
|
|
230
|
+
# an unhealthy schema endpoint without locking the application
|
|
231
|
+
# into a permanently-stale denial.
|
|
232
|
+
#
|
|
233
|
+
# An empty `class_name` short-circuits to an `:unresolvable`
|
|
234
|
+
# entry — `permits?` will refuse the call rather than dispatching
|
|
235
|
+
# `schema("")` to the upstream client.
|
|
236
|
+
def fetch(class_name)
|
|
237
|
+
key = class_name.to_s
|
|
238
|
+
return unresolvable_entry if key.empty?
|
|
239
|
+
|
|
240
|
+
cached = @cache_mutex.synchronize { @cache[key] }
|
|
241
|
+
return cached if cached && !stale?(cached)
|
|
242
|
+
|
|
243
|
+
client = schema_client || default_client_safe
|
|
244
|
+
entry =
|
|
245
|
+
if client.nil?
|
|
246
|
+
# No client configured (Parse.setup never called, etc.) —
|
|
247
|
+
# treat as unresolvable so we fail closed instead of
|
|
248
|
+
# crashing inside the begin block with NoMethodError.
|
|
249
|
+
unresolvable_entry
|
|
250
|
+
else
|
|
251
|
+
begin
|
|
252
|
+
response = client.schema(key)
|
|
253
|
+
if response&.success?
|
|
254
|
+
schema = response.result || {}
|
|
255
|
+
clp = schema["classLevelPermissions"] || {}
|
|
256
|
+
kind = clp.empty? ? :no_clp : :cached_clp
|
|
257
|
+
CacheEntry.new(kind: kind, clp: clp, fetched_at: monotonic_now)
|
|
258
|
+
else
|
|
259
|
+
unresolvable_entry
|
|
260
|
+
end
|
|
261
|
+
rescue StandardError
|
|
262
|
+
unresolvable_entry
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
@cache_mutex.synchronize { @cache[key] = entry }
|
|
267
|
+
entry
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def unresolvable_entry
|
|
271
|
+
CacheEntry.new(kind: :unresolvable, clp: nil, fetched_at: monotonic_now)
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# `stale?` is TTL-domain-aware: positive entries use `@cache_ttl`
|
|
275
|
+
# (defaults to {POSITIVE_TTL} = 3600s), unresolvable entries use
|
|
276
|
+
# the much shorter {NEGATIVE_TTL} (5s) so the next attempt can
|
|
277
|
+
# quickly recover from a transient failure. Both are evaluated in
|
|
278
|
+
# the same monotonic clock domain to avoid NTP-related drift.
|
|
279
|
+
def stale?(entry)
|
|
280
|
+
return false if entry.fetched_at.nil?
|
|
281
|
+
ttl = entry.kind == :unresolvable ? NEGATIVE_TTL : @cache_ttl
|
|
282
|
+
return false if ttl.nil?
|
|
283
|
+
return false if ttl.respond_to?(:infinite?) && ttl.infinite?
|
|
284
|
+
(monotonic_now - entry.fetched_at) > ttl
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
# `default_client` raises if no client is configured; wrap it so
|
|
288
|
+
# `fetch` can fall through to {unresolvable_entry} instead.
|
|
289
|
+
def default_client_safe
|
|
290
|
+
default_client
|
|
291
|
+
rescue StandardError
|
|
292
|
+
nil
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# Emit a one-shot-per-class warning when `permits?` first denies
|
|
296
|
+
# because the schema is unresolvable. Without this, a quiet
|
|
297
|
+
# outage would silently break every scoped query; with it,
|
|
298
|
+
# operators see a single banner per class and can investigate.
|
|
299
|
+
def warn_unresolvable_once!(class_name)
|
|
300
|
+
@warned_unresolvable_classes ||= Set.new
|
|
301
|
+
key = class_name.to_s
|
|
302
|
+
return if @warned_unresolvable_classes.include?(key)
|
|
303
|
+
@warned_unresolvable_classes << key
|
|
304
|
+
warn "[Parse::CLPScope:SECURITY] schema for '#{key}' is " \
|
|
305
|
+
"unresolvable (network error, 5xx, missing client, or " \
|
|
306
|
+
"raised exception); FAILING CLOSED on all non-master " \
|
|
307
|
+
"queries for this class for the next #{NEGATIVE_TTL}s. " \
|
|
308
|
+
"Investigate the schema endpoint or pre-populate the " \
|
|
309
|
+
"cache via Parse::CLPScope.__cache_put. Subsequent " \
|
|
310
|
+
"denials for the same class will not re-warn."
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def monotonic_now
|
|
314
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
def default_client
|
|
318
|
+
Parse::Client.client(:default)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
def user_identity?(entry)
|
|
322
|
+
s = entry.to_s
|
|
323
|
+
s != "*" && !s.start_with?("role:")
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def walk_and_delete!(node, strip_set)
|
|
327
|
+
case node
|
|
328
|
+
when Hash
|
|
329
|
+
strip_set.each { |k| node.delete(k) }
|
|
330
|
+
node.each_value { |v| walk_and_delete!(v, strip_set) }
|
|
331
|
+
when Array
|
|
332
|
+
node.each { |v| walk_and_delete!(v, strip_set) }
|
|
333
|
+
end
|
|
334
|
+
node
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
def any_pointer_matches?(doc, pointer_fields, user_id)
|
|
338
|
+
return false unless doc.is_a?(Hash)
|
|
339
|
+
pointer_fields.any? do |field|
|
|
340
|
+
val = doc[field] || doc[field.to_sym]
|
|
341
|
+
if val.is_a?(Hash)
|
|
342
|
+
return true if val["objectId"] == user_id || val[:objectId] == user_id
|
|
343
|
+
elsif val.is_a?(Array)
|
|
344
|
+
return true if val.any? do |v|
|
|
345
|
+
v.is_a?(Hash) && (v["objectId"] == user_id || v[:objectId] == user_id)
|
|
346
|
+
end
|
|
347
|
+
end
|
|
348
|
+
mongo_val = doc["_p_#{field}"] || doc[:"_p_#{field}"]
|
|
349
|
+
if mongo_val.is_a?(String) && mongo_val.include?("$")
|
|
350
|
+
_cls, oid = mongo_val.split("$", 2)
|
|
351
|
+
return true if oid == user_id
|
|
352
|
+
end
|
|
353
|
+
false
|
|
354
|
+
end
|
|
355
|
+
end
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
@cache_ttl = POSITIVE_TTL
|
|
359
|
+
@warned_unresolvable_classes = Set.new
|
|
360
|
+
end
|
|
361
|
+
end
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
# encoding: UTF-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "monitor"
|
|
5
|
+
|
|
6
|
+
module Parse
|
|
7
|
+
module LiveQuery
|
|
8
|
+
# Circuit breaker pattern for connection failure handling.
|
|
9
|
+
#
|
|
10
|
+
# Prevents repeated connection attempts when the server is unavailable,
|
|
11
|
+
# allowing time for recovery before retrying.
|
|
12
|
+
#
|
|
13
|
+
# States:
|
|
14
|
+
# - :closed - Normal operation, requests allowed
|
|
15
|
+
# - :open - Too many failures, requests blocked
|
|
16
|
+
# - :half_open - Testing if service recovered
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# breaker = CircuitBreaker.new(failure_threshold: 5, reset_timeout: 60.0)
|
|
20
|
+
#
|
|
21
|
+
# if breaker.allow_request?
|
|
22
|
+
# begin
|
|
23
|
+
# connect_to_server
|
|
24
|
+
# breaker.record_success
|
|
25
|
+
# rescue => e
|
|
26
|
+
# breaker.record_failure
|
|
27
|
+
# end
|
|
28
|
+
# else
|
|
29
|
+
# # Circuit is open, wait before retrying
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
class CircuitBreaker
|
|
33
|
+
# Valid circuit states
|
|
34
|
+
STATES = [:closed, :open, :half_open].freeze
|
|
35
|
+
|
|
36
|
+
# Default number of failures before opening circuit
|
|
37
|
+
DEFAULT_FAILURE_THRESHOLD = 5
|
|
38
|
+
|
|
39
|
+
# Default seconds before transitioning from open to half_open
|
|
40
|
+
DEFAULT_RESET_TIMEOUT = 60.0
|
|
41
|
+
|
|
42
|
+
# Default number of successful requests in half_open before closing
|
|
43
|
+
DEFAULT_HALF_OPEN_REQUESTS = 1
|
|
44
|
+
|
|
45
|
+
# @return [Symbol] current state (:closed, :open, :half_open)
|
|
46
|
+
attr_reader :state
|
|
47
|
+
|
|
48
|
+
# @return [Integer] number of consecutive failures
|
|
49
|
+
attr_reader :failure_count
|
|
50
|
+
|
|
51
|
+
# @return [Integer] number of successful requests in half_open
|
|
52
|
+
attr_reader :success_count
|
|
53
|
+
|
|
54
|
+
# @return [Time, nil] when the last failure occurred
|
|
55
|
+
attr_reader :last_failure_at
|
|
56
|
+
|
|
57
|
+
# @return [Integer] failure threshold before opening
|
|
58
|
+
attr_reader :failure_threshold
|
|
59
|
+
|
|
60
|
+
# @return [Float] seconds before half_open transition
|
|
61
|
+
attr_reader :reset_timeout
|
|
62
|
+
|
|
63
|
+
# Create a new circuit breaker
|
|
64
|
+
# @param failure_threshold [Integer] failures before opening circuit
|
|
65
|
+
# @param reset_timeout [Float] seconds before testing recovery
|
|
66
|
+
# @param half_open_requests [Integer] successes needed to close
|
|
67
|
+
# @param on_state_change [Proc, nil] callback for state changes
|
|
68
|
+
def initialize(failure_threshold: DEFAULT_FAILURE_THRESHOLD,
|
|
69
|
+
reset_timeout: DEFAULT_RESET_TIMEOUT,
|
|
70
|
+
half_open_requests: DEFAULT_HALF_OPEN_REQUESTS,
|
|
71
|
+
on_state_change: nil)
|
|
72
|
+
@failure_threshold = failure_threshold
|
|
73
|
+
@reset_timeout = reset_timeout
|
|
74
|
+
@half_open_requests = half_open_requests
|
|
75
|
+
@on_state_change = on_state_change
|
|
76
|
+
|
|
77
|
+
@monitor = Monitor.new
|
|
78
|
+
@state = :closed
|
|
79
|
+
@failure_count = 0
|
|
80
|
+
@success_count = 0
|
|
81
|
+
@last_failure_at = nil
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Check if a request is allowed
|
|
85
|
+
# @return [Boolean]
|
|
86
|
+
# @note Thread-safe. Callbacks are invoked outside the synchronized block.
|
|
87
|
+
def allow_request?
|
|
88
|
+
state_change = nil
|
|
89
|
+
|
|
90
|
+
result = @monitor.synchronize do
|
|
91
|
+
case @state
|
|
92
|
+
when :closed
|
|
93
|
+
true
|
|
94
|
+
when :open
|
|
95
|
+
if Time.now - @last_failure_at >= @reset_timeout
|
|
96
|
+
state_change = transition_to_internal(:half_open)
|
|
97
|
+
true
|
|
98
|
+
else
|
|
99
|
+
false
|
|
100
|
+
end
|
|
101
|
+
when :half_open
|
|
102
|
+
@success_count < @half_open_requests
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Invoke callback outside synchronized block to prevent deadlocks
|
|
107
|
+
notify_state_change(state_change) if state_change
|
|
108
|
+
|
|
109
|
+
result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Record a successful request
|
|
113
|
+
# @return [void]
|
|
114
|
+
# @note Thread-safe. Callbacks are invoked outside the synchronized block.
|
|
115
|
+
def record_success
|
|
116
|
+
state_change = nil
|
|
117
|
+
|
|
118
|
+
@monitor.synchronize do
|
|
119
|
+
case @state
|
|
120
|
+
when :half_open
|
|
121
|
+
@success_count += 1
|
|
122
|
+
if @success_count >= @half_open_requests
|
|
123
|
+
Logging.info("Circuit breaker closing after successful recovery")
|
|
124
|
+
state_change = reset_internal!
|
|
125
|
+
end
|
|
126
|
+
when :closed
|
|
127
|
+
@failure_count = 0
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Invoke callback outside synchronized block to prevent deadlocks
|
|
132
|
+
notify_state_change(state_change) if state_change
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Record a failed request
|
|
136
|
+
# @return [void]
|
|
137
|
+
# @note Thread-safe. Callbacks are invoked outside the synchronized block.
|
|
138
|
+
def record_failure
|
|
139
|
+
state_change = nil
|
|
140
|
+
|
|
141
|
+
@monitor.synchronize do
|
|
142
|
+
@failure_count += 1
|
|
143
|
+
@last_failure_at = Time.now
|
|
144
|
+
|
|
145
|
+
case @state
|
|
146
|
+
when :closed
|
|
147
|
+
if @failure_count >= @failure_threshold
|
|
148
|
+
Logging.warn("Circuit breaker opening", failures: @failure_count)
|
|
149
|
+
state_change = transition_to_internal(:open)
|
|
150
|
+
end
|
|
151
|
+
when :half_open
|
|
152
|
+
Logging.warn("Circuit breaker re-opening from half_open")
|
|
153
|
+
state_change = transition_to_internal(:open)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Invoke callback outside synchronized block to prevent deadlocks
|
|
158
|
+
notify_state_change(state_change) if state_change
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# Reset the circuit breaker to closed state
|
|
162
|
+
# @return [void]
|
|
163
|
+
# @note Thread-safe. Callbacks are invoked outside the synchronized block.
|
|
164
|
+
def reset!
|
|
165
|
+
state_change = @monitor.synchronize { reset_internal! }
|
|
166
|
+
|
|
167
|
+
# Invoke callback outside synchronized block to prevent deadlocks
|
|
168
|
+
notify_state_change(state_change) if state_change
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
# Check if circuit is open (blocking requests)
|
|
172
|
+
# @return [Boolean]
|
|
173
|
+
def open?
|
|
174
|
+
@monitor.synchronize { @state == :open }
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Check if circuit is closed (allowing requests)
|
|
178
|
+
# @return [Boolean]
|
|
179
|
+
def closed?
|
|
180
|
+
@monitor.synchronize { @state == :closed }
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Check if circuit is half_open (testing recovery)
|
|
184
|
+
# @return [Boolean]
|
|
185
|
+
def half_open?
|
|
186
|
+
@monitor.synchronize { @state == :half_open }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
# Seconds until circuit transitions to half_open
|
|
190
|
+
# @return [Float, nil] nil if not open
|
|
191
|
+
def time_until_half_open
|
|
192
|
+
@monitor.synchronize do
|
|
193
|
+
return nil unless @state == :open && @last_failure_at
|
|
194
|
+
remaining = @reset_timeout - (Time.now - @last_failure_at)
|
|
195
|
+
[remaining, 0].max
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Get circuit breaker info as hash
|
|
200
|
+
# @return [Hash]
|
|
201
|
+
def info
|
|
202
|
+
@monitor.synchronize do
|
|
203
|
+
{
|
|
204
|
+
state: @state,
|
|
205
|
+
failure_count: @failure_count,
|
|
206
|
+
success_count: @success_count,
|
|
207
|
+
failure_threshold: @failure_threshold,
|
|
208
|
+
reset_timeout: @reset_timeout,
|
|
209
|
+
last_failure_at: @last_failure_at,
|
|
210
|
+
time_until_half_open: time_until_half_open,
|
|
211
|
+
}
|
|
212
|
+
end
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
private
|
|
216
|
+
|
|
217
|
+
# Transition to a new state (must be called with mutex held)
|
|
218
|
+
# @param new_state [Symbol]
|
|
219
|
+
# @return [Array<Symbol, Symbol>, nil] [old_state, new_state] if changed, nil otherwise
|
|
220
|
+
def transition_to_internal(new_state)
|
|
221
|
+
old_state = @state
|
|
222
|
+
return nil if old_state == new_state
|
|
223
|
+
|
|
224
|
+
@state = new_state
|
|
225
|
+
@success_count = 0 if new_state == :half_open
|
|
226
|
+
|
|
227
|
+
Logging.debug("Circuit breaker state change", from: old_state, to: new_state)
|
|
228
|
+
[old_state, new_state]
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# Reset internal state (must be called with mutex held)
|
|
232
|
+
# @return [Array<Symbol, Symbol>, nil] [old_state, :closed] if changed, nil otherwise
|
|
233
|
+
def reset_internal!
|
|
234
|
+
old_state = @state
|
|
235
|
+
@state = :closed
|
|
236
|
+
@failure_count = 0
|
|
237
|
+
@success_count = 0
|
|
238
|
+
@last_failure_at = nil
|
|
239
|
+
|
|
240
|
+
if old_state != :closed
|
|
241
|
+
Logging.debug("Circuit breaker reset", from: old_state, to: :closed)
|
|
242
|
+
[old_state, :closed]
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Notify state change callback outside of synchronized block
|
|
247
|
+
# @param state_change [Array<Symbol, Symbol>, nil] [old_state, new_state]
|
|
248
|
+
def notify_state_change(state_change)
|
|
249
|
+
return unless state_change && @on_state_change
|
|
250
|
+
|
|
251
|
+
old_state, new_state = state_change
|
|
252
|
+
@on_state_change.call(old_state, new_state)
|
|
253
|
+
end
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|