Class: Parse::Cache::UpstreamRoles

Inherits:
Object
  • Object
show all
Defined in:
lib/parse/cache/upstream_roles.rb

Overview

Read-only consumer of Parse Server's own role cache.

Parse Server caches <appId>:role:<userId> as a JSON array of "role:NAME" strings representing the transitive closure. When the SDK already holds a user id from a trusted source (a webhook payload, most usefully), reading that entry skips the role-graph walk entirely, and the value was computed by Parse Server's own code so there is no divergence between our closure and the one the server enforces.

We never write this keyspace. Our closure is depth-capped while Parse Server's is not, so injecting ours would hand the server a strict subset in a deep hierarchy, which it would then read back as authoritative and under-permission users in windows that are close to undiagnosable.

Trust. This makes the Parse Server cache database part of the SDK's authorization trust base: the array feeds permission_strings, which is the only input to both the _rperm match and the CLP gate on the mongo-direct path. Anyone who can write that database can grant themselves roles. The credential should be restricted accordingly:

ACL SETUSER parse-stack-role-reader on >SECRET \
  ~<appId>:role:* resetchannels -@all +get +pttl

+pttl is required alongside +get: the freshness guard below cannot run without it, and PTTL is a separate ACL command from GET.

Constant Summary collapse

DEFAULT_UPSTREAM_TTL_MS =

Parse Server's RedisCacheAdapter default TTL, used to derive an entry's write time from its remaining TTL. Configurable because an operator supplies the adapter instance and may have passed another.

30_000
DEFAULT_MAX_TTL_MS =

Reject an entry whose remaining TTL exceeds this. Neither Parse Server entry carries its own write time, so a very long TTL means either a misconfigured adapter or a value we cannot age, and an un-ageable authorization input is not one to trust.

60_000
MAX_ROLE_COUNT =

Caps on the decoded array. Role counts scale with tenants, so these are generous: a low cap would be an outage, not a safeguard. An unbounded array becomes an unbounded $in sent to MongoDB.

4096
MAX_ROLE_NAME_LENGTH =
256
INVALID_ROLE_NAME =

Role names may legitimately contain / and : (scoped conventions such as owner/t:<id>/p:<id> are common), so this only excludes control characters and the NUL byte. A tighter pattern would reject every role in such an app and fail closed into total loss of role access.

/[\x00-\x1f\x7f]/.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(client:, app_id:, roles_plane: nil, upstream_ttl_ms: DEFAULT_UPSTREAM_TTL_MS, max_ttl_ms: DEFAULT_MAX_TTL_MS) ⇒ UpstreamRoles

Returns a new instance of UpstreamRoles.

Parameters:

  • client (Object)

    a redis-rb-shaped client answering get and pttl. Injected rather than constructed so tests and alternative transports work, and so the caller owns connection lifecycle.

  • app_id (String)

    Parse application id, used to build the key.

  • roles_plane (Parse::Cache::SubCache, nil) (defaults to: nil)

    our own role plane, consulted for the invalidation epoch.

  • upstream_ttl_ms (Integer) (defaults to: DEFAULT_UPSTREAM_TTL_MS)

    Parse Server's configured entry TTL.

  • max_ttl_ms (Integer) (defaults to: DEFAULT_MAX_TTL_MS)

    reject entries with more remaining than this.



69
70
71
72
73
74
75
76
77
# File 'lib/parse/cache/upstream_roles.rb', line 69

def initialize(client:, app_id:, roles_plane: nil,
               upstream_ttl_ms: DEFAULT_UPSTREAM_TTL_MS,
               max_ttl_ms: DEFAULT_MAX_TTL_MS)
  @client = client
  @app_id = app_id.to_s
  @roles_plane = roles_plane
  @upstream_ttl_ms = upstream_ttl_ms
  @max_ttl_ms = max_ttl_ms
end

Instance Attribute Details

#app_idObject (readonly)

Returns the value of attribute app_id.



59
60
61
# File 'lib/parse/cache/upstream_roles.rb', line 59

def app_id
  @app_id
end

Instance Method Details

#fresh?(pttl_ms) ⇒ Boolean

Whether the entry is newer than our last role invalidation.

Parse Server does not clear its role cache on a _Role delete, so without this gate a delete we handled would be undone by reading their surviving entry. Neither entry records its write time, so it is derived from what remains of the configured TTL.

Returns:

  • (Boolean)


117
118
119
120
121
122
123
124
125
126
127
# File 'lib/parse/cache/upstream_roles.rb', line 117

def fresh?(pttl_ms)
  return true if @roles_plane.nil?
  # A remaining TTL larger than the configured starting TTL cannot be
  # aged with this reader's assumptions. Subtracting it would produce a
  # negative elapsed time and a future write timestamp, which could pass
  # the epoch gate and re-admit a pre-invalidation entry. Fail closed.
  return false if pttl_ms > @upstream_ttl_ms
  elapsed_ms = @upstream_ttl_ms - pttl_ms
  written_at = Time.now.to_f - (elapsed_ms / 1000.0)
  @roles_plane.fresh_since_epoch?(written_at)
end

#key_for(user_id) ⇒ String

Key Parse Server writes for a user's role closure.

Returns:



81
82
83
# File 'lib/parse/cache/upstream_roles.rb', line 81

def key_for(user_id)
  "#{@app_id}:role:#{user_id}"
end

#roles_for(user_id) ⇒ Set<String>?

Read and validate the upstream closure.

Parameters:

Returns:

  • (Set<String>, nil)

    bare role names (no role: prefix), or nil on any miss, malformed value, stale entry, or transport error. Every failure is a miss, so the caller falls back to computing the closure itself. This never fails open.



92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/parse/cache/upstream_roles.rb', line 92

def roles_for(user_id)
  return nil if user_id.nil? || user_id.to_s.empty?
  key = key_for(user_id)

  raw = @client.get(key)
  return nil if raw.nil?

  ttl = safe_pttl(key)
  return nil unless usable_ttl?(ttl)
  return nil unless fresh?(ttl)

  decode(raw)
rescue StandardError
  # Transport failures, malformed JSON, anything: treat as a miss. Note
  # the deliberate absence of the exception message, which would carry the
  # key and therefore the user id.
  nil
end

#shares_database_with?(scanner) ⇒ Boolean

Whether our own database also holds Parse Server's cache entries, which means the two are the same database.

The probe deliberately runs against OUR connection, looking for THEIR key pattern, rather than the reverse. An earlier version wrote a sentinel on our side and tried to read it back through the upstream client, which cannot work under the credential this class documents: that ACL permits only <appId>:role:*, so reading a parse-stack:probe:* key returns NOPERM. The rescue then swallowed it and reported the databases as isolated, so the check passed exactly when it was least able to see anything.

Inverting it also removes the write. We never need to create a key to answer the question, and our own connection has the permissions to scan its own database.

This can only ever prove sharing, never isolation. A false return means "no Parse-Server-shaped key was seen", which is what a genuinely separate database looks like AND what a shared database looks like before Parse Server has cached its first role closure. A freshly deployed stack is in that second state, and it is exactly when an operator is most likely to run the check. Callers must not report false as "isolated"; see Redis#verify_upstream_isolation!, which pairs this with a sentinel round-trip that CAN establish the negative.

Parameters:

  • scanner (Object)

    our own store, answering scan_exist? or a redis-rb-shaped scan.

Returns:

  • (Boolean)

    true when Parse Server's keys are visible in our database. False means only "not detected".



159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
# File 'lib/parse/cache/upstream_roles.rb', line 159

def shares_database_with?(scanner)
  pattern = "#{@app_id}:role:*"
  client = scanner.respond_to?(:scan) ? scanner : nil
  return false if client.nil?

  cursor = "0"
  # Bounded: a handful of iterations is enough to answer "are any of their
  # keys here", and an unbounded scan on a large database would be a
  # startup stall.
  8.times do
    cursor, keys = client.scan(cursor, match: pattern, count: 100)
    # Callers that use a broad pattern are responsible for filtering out
    # anything that is not a genuine Parse Server entry before it reaches
    # here. `Parse::Cache::Redis` wraps its client in a scanner that
    # keeps only keys matching the upstream `<appId>:role:<userId>`
    # shape, so the filtering lives next to the wiring rather than being
    # duplicated in both places.
    return true unless keys.empty?
    break if cursor == "0"
  end
  false
rescue StandardError
  # Cannot tell. Report not-shared so a probe failure never blocks
  # startup; the warning path is advisory, not a gate.
  false
end