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_ttl knob; 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

Class Method Summary collapse

Class Attribute Details

.cache_ttlObject

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_clientObject

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

Raises:



148
149
150
151
152
# File 'lib/parse/clp_scope.rb', line 148

def assert_permitted!(class_name, op, permission_strings, client: nil)
  return if permits?(class_name, op, permission_strings, client: client)
  raise Denied.new(class_name, op,
                   "CLP refuses #{op} on '#{class_name}' for the current scope.")
end

.cache_statsObject



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?.

Parameters:

  • class_name (String)

    Parse class name.

  • op (Symbol)

    one of OPERATIONS.

  • claims (Enumerable<String>)

    public, user, and role claims.

  • authenticated (Boolean)

    whether authentication is established.

  • user_id (String, nil) (defaults to: nil)

    concrete authenticated _User.objectId.

  • client (Parse::Client) (defaults to: nil)

    application whose schema owns the CLP.

Returns:



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

Returns:

  • (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, permission_strings, client: nil)
  return true if permission_strings.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 = permission_strings.is_a?(Set) ? permission_strings : permission_strings.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, permission_strings, client: nil)
  return EMPTY_SET if permission_strings.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 = permission_strings.is_a?(Set) ? permission_strings : permission_strings.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.

Parameters:

  • class_name (String)

    Parse class name.

  • op (Symbol)

    CLP operation.

Returns:

  • (Array<String>, nil)

    pointer field names, or nil when the operation has no corresponding user-field constraint.



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