Module: Parse::CLPScope
- Defined in:
- lib/parse/clp_scope.rb
Defined Under Namespace
Classes: AccessEvaluation, CacheEntry, Denied
Constant Summary collapse
- OPERATIONS =
%i[find count get create update delete].freeze
- POSITIVE_TTL =
Positive-cache TTL (seconds): how long a successful schema fetch is reused. Mirrors the previous module-level
@cache_ttlknob; kept identical to preserve backwards-compatible cache behavior. 3600- NEGATIVE_TTL =
Negative-cache TTL (seconds): how long we remember that a class's schema was unresolvable. Short so a transient network blip doesn't gridlock the application for an hour, but non-zero so a permanent failure (auth credential rotated, schema endpoint disabled) doesn't melt the schema endpoint with a thundering herd of retries at request rate.
5
Class Attribute Summary collapse
-
.cache_ttl ⇒ Object
Returns the value of attribute cache_ttl.
-
.schema_client ⇒ Object
Returns the value of attribute schema_client.
Class Method Summary collapse
-
.__cache_put(class_name, clp:, client: nil) ⇒ Object
Test/operator-facing hook: pre-populate the cache with a known CLP for
class_name. - .assert_permitted!(class_name, op, permission_strings, client: nil) ⇒ Object
- .cache_stats ⇒ Object
-
.evaluate_access(class_name, op, claims:, authenticated:, user_id: nil, client: nil) ⇒ AccessEvaluation
Evaluate the same mutually-exclusive CLP branches Parse Server uses before applying a query to a particular row.
- .filter_by_pointer_fields(documents, pointer_fields, user_id) ⇒ Object
- .invalidate!(class_name, client: nil) ⇒ Object
- .permits?(class_name, op, permission_strings, client: nil) ⇒ Boolean
- .pointer_fields_for(class_name, op, client: nil) ⇒ Object
- .protected_fields_for(class_name, permission_strings, client: nil) ⇒ Object
- .redact_protected_fields!(documents, strip_set) ⇒ Object
- .reset_cache! ⇒ Object
-
.user_fields_for(class_name, op, client: nil) ⇒ Array<String>?
Return Parse Server's top-level
readUserFieldsorwriteUserFieldsrow constraint for an operation.
Class Attribute Details
.cache_ttl ⇒ Object
Returns the value of attribute cache_ttl.
90 91 92 |
# File 'lib/parse/clp_scope.rb', line 90 def cache_ttl @cache_ttl end |
.schema_client ⇒ Object
Returns the value of attribute schema_client.
90 91 92 |
# File 'lib/parse/clp_scope.rb', line 90 def schema_client @schema_client end |
Class Method Details
.__cache_put(class_name, clp:, client: nil) ⇒ Object
Test/operator-facing hook: pre-populate the cache with a known
CLP for class_name. An empty/nil clp is recorded as
:no_clp (matches the public-default semantics Parse Server
exposes when no CLP is configured); a non-empty clp is
recorded as :cached_clp (the standard happy path).
358 359 360 361 362 363 364 |
# File 'lib/parse/clp_scope.rb', line 358 def __cache_put(class_name, clp:, client: nil) normalized = clp || {} kind = normalized.empty? ? :no_clp : :cached_clp entry = CacheEntry.new(kind: kind, clp: normalized, fetched_at: monotonic_now) @cache_mutex.synchronize { @cache[cache_key(class_name, client)] = entry } entry end |
.assert_permitted!(class_name, op, permission_strings, client: nil) ⇒ Object
148 149 150 151 152 |
# File 'lib/parse/clp_scope.rb', line 148 def assert_permitted!(class_name, op, , client: nil) return if permits?(class_name, op, , client: client) raise Denied.new(class_name, op, "CLP refuses #{op} on '#{class_name}' for the current scope.") end |
.cache_stats ⇒ Object
343 344 345 346 347 348 349 350 351 |
# File 'lib/parse/clp_scope.rb', line 343 def cache_stats @cache_mutex.synchronize do { size: @cache.size, class_names: @cache.keys.map(&:last).uniq.sort, scopes: @cache.keys.map(&:first).uniq.sort, } end end |
.evaluate_access(class_name, op, claims:, authenticated:, user_id: nil, client: nil) ⇒ AccessEvaluation
Evaluate the same mutually-exclusive CLP branches Parse Server uses
before applying a query to a particular row. Public, direct-user, and
role grants bypass pointer constraints. requiresAuthentication does
not: when pointer/user fields exist, a concrete authenticated _User
id must still match the row.
Unlike permits?, schema lookup failures are reported as :unknown
rather than collapsed into a denial. Authorization-enforcing callers
can still fail closed by accepting only Parse::CLPScope::AccessEvaluation#allowed?.
211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 |
# File 'lib/parse/clp_scope.rb', line 211 def evaluate_access(class_name, op, claims:, authenticated:, user_id: nil, client: nil) op = op.to_sym unless OPERATIONS.include?(op) return access_evaluation(:unknown, reason: :unsupported_operation) end entry = fetch(class_name, client: client) case entry.kind when :unresolvable return access_evaluation(:unknown, reason: :clp_unresolvable) when :no_clp return access_evaluation(:allowed, via: :public_default) end op_map = entry.clp[op.to_s] || entry.clp[op] # Parse Server treats an omitted operation map as public. Because that # is already a base grant, grouped pointer fields do not narrow it. return access_evaluation(:allowed, via: :public_default) if op_map.nil? unless op_map.is_a?(Hash) return access_evaluation(:unknown, reason: :malformed_clp) end claim_set = claims.is_a?(Set) ? claims : Set.new(Array(claims).map(&:to_s)) role_claims = op_map.each_with_object([]) do |(principal, allowed), memo| key = principal.to_s memo << key if allowed == true && key.start_with?("role:") end.freeze if op_map["*"] == true || op_map[:"*"] == true return access_evaluation(:allowed, via: :public, role_claims: role_claims) end direct_claim = op_map.find do |principal, allowed| key = principal.to_s allowed == true && key != "*" && key != "requiresAuthentication" && key != "pointerFields" && claim_set.include?(key) end if direct_claim return access_evaluation( :allowed, via: direct_claim.first.to_s.start_with?("role:") ? :role : :user, role_claims: role_claims, ) end pointer_fields = pointer_fields_from(entry.clp, op) requires_authentication = op_map["requiresAuthentication"] == true || op_map[:requiresAuthentication] == true if pointer_fields.any? if authenticated == true && !user_id.to_s.empty? return access_evaluation( :allowed, via: :pointer, pointer_fields: pointer_fields, role_claims: role_claims, ) end status = authenticated == true ? :unknown : :denied reason = authenticated == true ? :concrete_user_required : :authentication_required return access_evaluation(status, role_claims: role_claims, reason: reason) end if requires_authentication && authenticated == true return access_evaluation(:allowed, via: :authenticated, role_claims: role_claims) end reason = requires_authentication ? :authentication_required : :clp_denied access_evaluation(:denied, role_claims: role_claims, reason: reason) end |
.filter_by_pointer_fields(documents, pointer_fields, user_id) ⇒ Object
316 317 318 319 320 |
# File 'lib/parse/clp_scope.rb', line 316 def filter_by_pointer_fields(documents, pointer_fields, user_id) return documents if pointer_fields.nil? || pointer_fields.empty? return [] if user_id.nil? || user_id.to_s.empty? documents.select { |doc| any_pointer_matches?(doc, pointer_fields, user_id.to_s) } end |
.invalidate!(class_name, client: nil) ⇒ Object
322 323 324 325 326 327 328 329 330 331 332 |
# File 'lib/parse/clp_scope.rb', line 322 def invalidate!(class_name, client: nil) class_key = class_name.to_s @cache_mutex.synchronize do if client @cache.delete(cache_key(class_key, client)) else @cache.delete_if { |(_scope, cached_class), _entry| cached_class == class_key } end end nil end |
.permits?(class_name, op, permission_strings, client: nil) ⇒ Boolean
92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 |
# File 'lib/parse/clp_scope.rb', line 92 def permits?(class_name, op, , client: nil) return true if .nil? # master-key bypass return true unless OPERATIONS.include?(op) entry = fetch(class_name, client: client) # `fetch` never returns nil now — it returns an `:unresolvable` # CacheEntry on failure so callers must branch on `kind`. case entry.kind when :unresolvable # FAIL CLOSED. The SDK is the only enforcement layer on the # mongo-direct path; without a verified CLP we can't tell # whether the class is public or admin-only, and the safe # default is to refuse rather than silently surrender row # filtering. Operators who want a different posture can # pre-populate the cache via {.__cache_put} from a startup # hook or static config. warn_unresolvable_once!(class_name) return false when :no_clp # Schema fetch succeeded; class has no CLP configured. # Parse Server's default is public, so permit. return true end op_map = entry.clp[op.to_s] || entry.clp[op] # nil op_map: the operation has no CLP entry. Parse Server's # default is public, so permit. return true if op_map.nil? # Empty op_map (`delete: {}` etc.): nobody but master-key. # Master-key already short-circuited above, so deny here. return false if op_map.is_a?(Hash) && op_map.empty? claim_set = .is_a?(Set) ? : .to_set op_map.each do |principal, allowed| case principal.to_s when "*" return true if allowed == true when "requiresAuthentication" return true if allowed == true && claim_set.any? { |e| user_identity?(e) } when "pointerFields" # Value is an Array of pointer field names, not a boolean. # At the boundary, permit iff the claim set has a user # identity to satisfy the constraint with; the actual # row-by-row check runs post-fetch via {.pointer_fields_for}. next if allowed.nil? || (allowed.respond_to?(:empty?) && allowed.empty?) return true if claim_set.any? { |e| user_identity?(e) } else # Bare userObjectId or "role:Name" — claim-set match. return true if allowed == true && claim_set.include?(principal.to_s) end end false end |
.pointer_fields_for(class_name, op, client: nil) ⇒ Object
154 155 156 157 158 159 160 161 162 163 164 165 166 167 |
# File 'lib/parse/clp_scope.rb', line 154 def pointer_fields_for(class_name, op, client: nil) entry = fetch(class_name, client: client) # No CLP at all, or schema unresolvable: there's no # pointerFields constraint to apply. (For :unresolvable the # caller's `permits?` already failed closed; this helper just # returns nil so a post-fetch row-filter step is skipped.) return nil if entry.kind == :no_clp || entry.kind == :unresolvable op_map = entry.clp[op.to_s] || entry.clp[op] return nil unless op_map.is_a?(Hash) fields = op_map["pointerFields"] || op_map[:pointerFields] return nil if fields.nil? arr = Array(fields).map(&:to_s) arr.empty? ? nil : arr end |
.protected_fields_for(class_name, permission_strings, client: nil) ⇒ Object
283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 |
# File 'lib/parse/clp_scope.rb', line 283 def protected_fields_for(class_name, , client: nil) return EMPTY_SET if .nil? entry = fetch(class_name, client: client) # No CLP / unresolvable: nothing to strip. For :unresolvable, # `permits?` already refused the query, so this branch is only # reached when callers ask for the protected-fields set directly # (e.g. for documentation or audit tooling). return EMPTY_SET if entry.kind == :no_clp || entry.kind == :unresolvable protected_map = entry.clp["protectedFields"] || entry.clp[:protectedFields] return EMPTY_SET if protected_map.nil? || protected_map.empty? strip = Set.new(Array(protected_map["*"] || protected_map[:"*"]).map(&:to_s)) claim_set = .is_a?(Set) ? : .to_set claim_set.each do |claim| next if claim == "*" override = protected_map[claim.to_s] || protected_map[claim.to_sym] next if override.nil? override_set = Set.new(Array(override).map(&:to_s)) strip &= override_set end strip.freeze end |
.redact_protected_fields!(documents, strip_set) ⇒ Object
309 310 311 312 313 314 |
# File 'lib/parse/clp_scope.rb', line 309 def redact_protected_fields!(documents, strip_set) return documents if documents.nil? || documents.empty? return documents if strip_set.nil? || strip_set.empty? documents.each { |doc| walk_and_delete!(doc, strip_set) } documents end |
.reset_cache! ⇒ Object
334 335 336 337 338 339 340 341 |
# File 'lib/parse/clp_scope.rb', line 334 def reset_cache! @cache_mutex.synchronize { @cache.clear } # Also drop the unresolvable-class warned-once registry so # tests that assert on `warn` emission for a class don't get # silenced by an earlier test's call. @warned_unresolvable_classes = Set.new nil end |
.user_fields_for(class_name, op, client: nil) ⇒ Array<String>?
Return Parse Server's top-level readUserFields or
writeUserFields row constraint for an operation. Unlike an
operation's pointerFields grant, these keys live alongside the
operation maps in classLevelPermissions and therefore need their
own lookup.
179 180 181 182 183 184 185 186 187 188 189 190 191 192 |
# File 'lib/parse/clp_scope.rb', line 179 def user_fields_for(class_name, op, client: nil) key = case op.to_sym when :find, :get, :count then "readUserFields" when :create, :update, :delete then "writeUserFields" end return nil if key.nil? entry = fetch(class_name, client: client) return nil if entry.kind == :no_clp || entry.kind == :unresolvable fields = entry.clp[key] || entry.clp[key.to_sym] arr = Array(fields).map(&:to_s) arr.empty? ? nil : arr end |