Module: Parse::CLPScope

Defined in:
lib/parse/clp_scope.rb

Defined Under Namespace

Classes: 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.



65
66
67
# File 'lib/parse/clp_scope.rb', line 65

def cache_ttl
  @cache_ttl
end

.schema_clientObject

Returns the value of attribute schema_client.



65
66
67
# File 'lib/parse/clp_scope.rb', line 65

def schema_client
  @schema_client
end

Class Method Details

.__cache_put(class_name, clp:) ⇒ 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).



208
209
210
211
212
213
214
# File 'lib/parse/clp_scope.rb', line 208

def __cache_put(class_name, clp:)
  normalized = clp || {}
  kind = normalized.empty? ? :no_clp : :cached_clp
  entry = CacheEntry.new(kind: kind, clp: normalized, fetched_at: monotonic_now)
  @cache_mutex.synchronize { @cache[class_name.to_s] = entry }
  entry
end

.assert_permitted!(class_name, op, permission_strings) ⇒ Object

Raises:



123
124
125
126
127
# File 'lib/parse/clp_scope.rb', line 123

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

.cache_statsObject



197
198
199
200
201
# File 'lib/parse/clp_scope.rb', line 197

def cache_stats
  @cache_mutex.synchronize do
    { size: @cache.size, class_names: @cache.keys.sort }
  end
end

.filter_by_pointer_fields(documents, pointer_fields, user_id) ⇒ Object



177
178
179
180
181
# File 'lib/parse/clp_scope.rb', line 177

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) ⇒ Object



183
184
185
186
# File 'lib/parse/clp_scope.rb', line 183

def invalidate!(class_name)
  @cache_mutex.synchronize { @cache.delete(class_name.to_s) }
  nil
end

.permits?(class_name, op, permission_strings) ⇒ Boolean

Returns:

  • (Boolean)


67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
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
# File 'lib/parse/clp_scope.rb', line 67

def permits?(class_name, op, permission_strings)
  return true if permission_strings.nil?  # master-key bypass
  return true unless OPERATIONS.include?(op)

  entry = fetch(class_name)
  # `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) ⇒ Object



129
130
131
132
133
134
135
136
137
138
139
140
141
142
# File 'lib/parse/clp_scope.rb', line 129

def pointer_fields_for(class_name, op)
  entry = fetch(class_name)
  # 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) ⇒ Object



144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/parse/clp_scope.rb', line 144

def protected_fields_for(class_name, permission_strings)
  return EMPTY_SET if permission_strings.nil?

  entry = fetch(class_name)
  # 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



170
171
172
173
174
175
# File 'lib/parse/clp_scope.rb', line 170

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



188
189
190
191
192
193
194
195
# File 'lib/parse/clp_scope.rb', line 188

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