v5.7.0 - Keyspaced Cache, Client-Owned Authorization, Access Decisions
Feature Release
A cache and authorization release: SDK cache keys move into a reserved, app-scoped keyspace with scoped clearing that can no longer reach FLUSHDB, session-token resolution and role expansion move out of Atlas Search into a new client-owned Parse::Authorization, identity and role caches become shareable across processes with webhook-driven invalidation, and Parse::User and Parse::Role gain effective-access predicates.
Changes
Cache keys move into a reserved, app-scoped keyspace
- NEW:
Parse::Cache::Keyspaceowns the physical layout of the response, identity, and role-cache keys the SDK writes, along with the glob patterns that clear them again, so key generation and eviction can no longer drift apart. Keys are laid out asparse-stack:v1:<app_scope>[:<namespace>]:<family>[:T:<tenant>]:<rest>, withcache,idn, androleas the families.app_scopeis a digest of the application id and the server URL rather than the raw values, so two apps sharing one Redis no longer collide when neither sets acache_namespace:, and an application id carrying a glob metacharacter cannot silently widen a SCAN pattern. Enable it withcache_keyspace: trueonParse.setup. - FIXED:
Parse::Client#clear_cache!no longer reachesFLUSHDBwhen a keyspace is configured. The wrapper fell through to a full flush whenever no namespace was set on it, which is the default, and the caching middleware could hold acache_namespace:the wrapper knew nothing about. On a shared Redis that destroyed co-tenant data, and it deleted the SDK's ownparse-stack:foc:v1:*create-locks, removingfirst_or_create!mutual exclusion for any lock held at the time with no error anywhere. Withcache_keyspace: true, every clear is a scoped SCAN inside the client's own keys and can only ever delete a subset of them.flush_db!remains the explicit opt-in for a full flush. Withoutcache_keyspace: truethe old behavior is unchanged, including theFLUSHDBfallback, so a reader who upgrades and changes nothing else is still exposed. - NEW: The keyspace-bound
Parse::Cache::ScopedViewexposed asclient.sdk_cacheacceptsfamily:andtenant:on#clear, and#delete_matching(pattern)evicts by glob. A pattern outside the view's own keyspace is a no-op rather than an unscoped scan, so the narrower API cannot become a back door to the blast radius the keyspace exists to close. - CHANGED:
client.cacheremains the store the application configured;client.sdk_cacheis the SDK's scoped view of it.Parse.cacheandParse.sdk_cachemirror the pair for the default client. - CHANGED: Scoped eviction issues
UNLINKrather thanDELwhere the client exposes it, so reclaiming a large eviction runs on a Redis background thread instead of stalling the server, falling back toDELon older clients. Each eviction emits aparse.cache.evictActiveSupport::Notificationsevent carryingpattern_digest,deleted, andduration_ms. The pattern is digested rather than logged because it embeds a URL digest and a cache tenant. - CHANGED:
Parse::Cache::Redisrefuses a Monetaprefix:option, which would rewrite the physical key layout underneath the wrapper and quietly restore the unscoped clearing the keyspace exists to prevent. Usecache_namespace:instead.
Cache clears can no longer widen past what was asked
- FIXED:
Parse::Cache::Redis#clearacceptedfamily:andtenant:and silently ignored them, falling through to the unnamespaced branch and issuingFLUSHDB. A request to clear one family therefore wiped the whole database, including other applications' entries and theparse-stack:foc:v1:*create-locks, whose loss silently removesfirst_or_create!mutual exclusion.clearnow raisesArgumentErrorfor that combination and points callers atbackend.scoped(keyspace).clear(family:), so a request to narrow a clear can no longer widen it. - FIXED:
cache_keyspace: trueon a store that cannot produce a scoped view, such as a plainMoneta.new(:Redis), left the store installed bare. Key composition still worked, so the deployment looked correctly keyspaced, butParse::Client#clear_cache!called the store's own unrestrictedclear, which on Redis isFLUSHDB. Such stores are now wrapped inParse::Cache::KeyspacedStore, which clears by enumerating keys under the keyspace where the store supportseach_key, and raisesParse::Cache::UnscopedClearRefusedwhere it cannot, rather than widening the clear to compensate.
The response cache's auth separation is enforced by construction
- IMPROVED: The auth discriminator on a response-cache key is now structural rather than incidental. A master-key request bypasses ACL, CLP, and
protectedFields, so for the same URL it returns a strictly fuller body than a session-token request, and two session-token requests can differ from each other throughprotectedFieldsentity rules and row ACLs. Previous versions already separated these, so this is not a fix for a cache that crossed those boundaries. What changes is that the separation can no longer be lost by accident:Parse::Cache::Keyspace#cache_keyhas no default forauth:and raises rather than building a key without one, and the generic key builder refuses the response-cache family outright. A raw session token is still never accepted as a key segment, only a truncated digest. - FIXED: A non-GET write now invalidates the resource for every caller. The previous invalidation could name only the anonymous variant, the master-key variant, and the caller's own, and had no way to enumerate the entries of sessions the process had never seen, so a write by one user left every other user reading a stale copy until the TTL expired. Every auth variant of one resource now shares a key prefix, so a scan-capable store evicts all of them with a single pattern. A GET miss still clears only the siblings it can name, since evicting resource-wide there would destroy other sessions' valid entries on every cache miss.
- CHANGED: Invalidation also deletes the pre-keyspace form of a key, so a rolling deploy does not leave old workers serving entries that a new worker's write should have killed. Pass
cache_delete_legacy_variants: falseto stop once every old worker is drained.
Identity and role caching share one backend across processes
- NEW:
Parse::Cache::ScopedView#identityand#rolesreturnParse::Cache::SubCacheplanes shaped for a client'sParse::Authorization::Context#identity_cacheand#role_cacheslots. Installing them replaces the default per-process memory caches with a shared backend, so every Puma worker and every dyno resolves a session token or a role closure against the same view instead of each holding its own. Each plane writes inside its own keyspace family, so clearing one can reach neither the other nor the response cache. - NEW: Each plane carries a per-subject generation counter and a plane-wide epoch, both for invalidating entries that cannot be named. Identity entries are keyed by session token and no reverse map from user id exists, so a
_Userwrite cannot enumerate them. Bumping that user's generation invalidates all of them in constant time, including tokens the process has never resolved, and without a master-key_Sessionquery. The epoch answers the different question of whether an entry written before a given moment is stale. It never moves backwards, so clock skew between workers cannot re-admit an entry a previous invalidation had rejected.
Role and session invalidation no longer depends on application discipline
- NEW:
Parse::Cache::Invalidationregisters the webhook triggers that keep the identity and role planes honest:after_saveandafter_deleteon_Role,after_saveandafter_deleteon_User, andafter_logouton_Session. It installs alongside the keyspace and is disabled withcache_invalidation_hooks: false. The previous contract asked applications to call invalidation from their own logout and role-mutation paths, which depended on every application remembering, and missed role changes made by any other client including a mobile SDK, the dashboard, and Node cloud code. The triggers cover writes from every source Parse Server sees. TTL remains the backstop, since the triggers require a webhook endpoint Parse Server can reach: this is TTL and hooks, not TTL or hooks. - FIXED: Registering a webhook handler replaced any handler already registered for the same trigger instead of composing with it, for every trigger except
after_saveandafter_delete. A secondafter_logoutregistration silently discarded the first, with file load order deciding the winner and nothing warning about it. Non-rejectableafter_*triggers now accumulate handlers the wayafter_savealways has. Rejectablebefore_*triggers deliberately keep replacing: a composite of those must deny if any handler denies, and folding the results with.lastwould discard an earlier rejection.
Session-token and role resolution move off Atlas Search and onto the client
- NEW:
Parse::Authorizationis the new owner of session-token resolution and role-closure expansion.client.authorizationreturns aParse::Authorization::Context, one perParse::Clientand never shared. This logic was originally written insideParse::AtlasSearch::Session, because$searchwas the first feature to run aggregations straight against MongoDB and therefore the first to enforce ACLs itself. Everything since reached back through it:Parse::ACLScopecalled into the Atlas Search namespace, andParse::MongoDB.aggregatecallsParse::ACLScope, soParse::Query#results_directon a plain query with no$searchanywhere in it depended on Atlas Search to decide who the caller was. Atlas Search is now one consumer alongside every other mongo-direct path. - FIXED: Two
Parse::Clientinstances addressing two different Parse applications no longer share one identity cache and one role cache. The previous caches, TTLs, and resolver were module-level globals reachable only throughParse.client, so a session token minted by a secondary application's Parse Server could be validated against the default application's/users/mecall and its cached role closures. EachParse::Authorization::Contextnow holds a back-reference to the one client it authorizes for and resolves exclusively through it. - NEW:
Parse::Authorization.configure(identity_cache:, role_cache:, identity_cache_ttl:, role_cache_ttl:, upstream_role_reader:, compare_upstream_roles:)configures the default client's context, as a boundary convenience matching the existing single-application shorthand. It is not the source of truth: configure a secondary application withother_client.authorization.configure(...)directly.Parse::Authorization.resolve(session_token, client:)requiresclient:with no default, because below the API boundary there is no such thing as "the" client, and defaulting it there is exactly the bug this release closes. - CHANGED: The identity plane is renamed
identity_cache(wassession_cache) and its TTL settingidentity_cache_ttl(wassession_cache_ttl), because it stores one user id per session token and never any_Sessionrow, and the old name led readers to reason about_Sessionsemantics that were never involved.Parse::Authorization::Resolved,::MemoryCache, and::InvalidSessionreplace the equivalents underParse::AtlasSearch::Session. - DEPRECATED:
Parse::AtlasSearch.session_cache=,.role_cache=,.session_cache_ttl,.role_cache_ttl,.upstream_role_reader,.compare_upstream_roles, andParse::AtlasSearch::Session.resolve/.invalidate/.invalidate_user_roles/.reset_caches!all still work and delegate to the default client's context, so existing code keeps running unchanged. Being module-level, they can only ever addressParse.client; code running against a secondary application must callother_client.authorizationdirectly. Slated for removal in 6.0.Parse::AtlasSearch.require_session_tokenis not part of this move: it decides whether$searchmay run anonymously, which is Atlas Search's own policy, not an identity concern.
Optional, compare-only read of Parse Server's own role cache
- NEW:
Parse::Cache::UpstreamRolesreads the<appId>:role:<userId>closure Parse Server writes for itself, so a caller holding a trusted user id, most usefully from a webhook payload, can skip the role-graph walk by callingclient.sdk_cache.upstream_roles.roles_for. Attach it by passingparse_cache_url:toParse::Cache::Redis. Without that option nothing upstream is read. - NEW: Role resolution itself does not consume the upstream value in this release. The SDK always computes its own closure, and the only built-in integration is
compare_upstream_roles, which reads the upstream entry purely to emit aparse.cache.role_compareevent carrying the size of each set and their symmetric difference. Nothing about the ACL decision changes. This is deliberate: the upstream value becomes an authorization input the moment it is consumed, so it stays observable-only until the two closures have been reconciled against real traffic. - NEW: The attachment is strictly read-only. The SDK never writes that keyspace, because its own closure is depth-capped while Parse Server's is not, so injecting a strict subset into a cache the server reads back as authoritative would under-permission users in windows that are close to undiagnosable.
- NEW: Every failure mode degrades to a miss so the caller recomputes the closure, and none of them fails open. The decoded value must be a JSON array of
role:-prefixed names within the configured count and length caps. An entry whose remaining PTTL cannot be read, is negative, or exceeds the configured ceiling is rejected, because an entry whose age cannot be derived is not one to trust as an authorization input. An entry written before the SDK's last role invalidation is rejected by the plane epoch, because Parse Server does not clear its own role cache on a_Roledelete. The reader needs only+getand+pttl, so the credential can be restricted to the role keyspace.
The upstream-isolation probe stops mistaking silence for isolation
- FIXED:
Parse::Cache::Redis#verify_upstream_isolation!reported an empty shared database as isolated. The SCAN probe can only ever prove sharing: an empty result is equally consistent with a genuinely separate database and with a shared one on which Parse Server has not yet cached a role closure, which is the state of every freshly deployed stack and exactly when an operator runs the check. The method now falls back to writing a random sentinel into the SDK's own database and asking the upstream connection to read it back, returningtruefor established isolation,falsefor established sharing, and:unknownwhen neither could be shown, which is what a credential restricted to~<appId>:role:*produces since the sentinel read is denied and a denial says nothing about which database denied it.:unknownis truthy, so callers branching on truthiness are unaffected. - NEW: A shared database is a real hazard: on Parse Server 9.10.0 and earlier a
_Rolewrite clears the cache withFLUSHDB, which takes the SDK's cached responses and itsfirst_or_create!create-locks with it. See parse-community/parse-server#10617. The probe warns rather than refusing to boot, since the hazard disappears entirely on a server carrying the scoped-clear fix.
Generation keys stop growing without bound
- FIXED: Generation keys in
Parse::Cache::SubCachenever expired. One key is written per user id on every_Userwebhook, which is unbounded Redis growth on a public signup flow. Generation keys now expire at twice the plane's entry TTL: expiry resets a counter to 0, which is also the value for a subject never bumped, so a counter that outlived its entries would let an entry written at generation 0 compare current again and reappear after having been invalidated.setclamps any longer per-call TTL to keep that invariant true.
Users and roles can inspect effective object access
- NEW:
Parse::UserandParse::Roleexposecan_read?,can_write?, andcan_delete?predicates backed by the newParse::Access.checkpolicy preflight. Each check combines the target ACL with its class'sget,update, ordeleteCLP; delete uses the ACL write grant. Direct and inherited user/role grants are supported, as are Parse Server'spointerFields,readUserFields, andwriteUserFieldsbranch semantics.Parse::Access::Decisionand the instanceaccess_decision/access_decisionshelpers exposeallowed,denied, orunknownresults; the boolean predicates accept only a definite allow and otherwise fail closed. Full rows with no ACL retain Parse Server's public default, while pointers, partial rows, unresolved schema/role evidence, and unsupported system-class rules remain unknown._Userreads and mutations honor Parse Server's self-access rules, and role-only checks cannot claim a concrete member's pointer or_Userself permission. CLP cache entries are isolated by Parse application so identically named classes cannot leak policy across clients. These helpers are advisory: the eventual Parse Server request is still authoritative.
Role graph queries accept the public API's default depth
- CHANGED: Raised the MongoDB role-graph query default and hard cap from 6 to 10, matching the existing
Parse::Role.all_for_userdefault. A ceiling below that default made the opt-in MongoDB fast path raiseArgumentErrorfor any caller who did not pass an explicitmax_depth:. The existing query-time budget continues to bound traversal work.
Dependencies and test infrastructure
- CHANGED: Bumped locked dependencies to their latest compatible releases:
mongo2.24.1 to 2.25.0,graphql2.6.5 to 2.6.6,csv3.3.5 to 3.3.6, andactivesupport8.1.3 to 8.1.3.1. - CHANGED: The integration test stack pins Parse Server 9.10.0, up from 9.9.0.
- CHANGED: The integration stack now backs Parse Server's own session, user, and role caches with Redis instead of its in-process adapter, so the
<appId>:role:<userId>entries the upstream reader consumes are observable from outside the container. It occupies database 1 while the SDK's cache and create-locks stay on database 0, and the adapter refuses to start on database 0. Leaving the URL unset keeps the in-process adapter and the previous behavior.
Behavior Notes
- Authorization is client-owned as of this release:
client.authorizationowns session-token resolution and role-closure expansion for that client alone.Parse::MongoDB(the URI, the driver connection, and collection selection) stays process-global in this release; the Mongo connection itself becomes client-owned in 6.0. - Because those two now have different owners,
Parse::MongoDBrecords the Parse application it was configured for andParse::MongoDB.verify_client!refuses a mongo-direct query authorized by a client belonging to a different one, raisingParse::MongoDB::ClientMismatch. Per-client authorization and a process-global connection are each safe alone and dangerous together: a secondary client would resolve its token correctly against its own application, build a correct_rpermallow-set for one of its users, then run the pipeline against the other application's database, where those user ids and role names match rows they have nothing to do with. Nothing about that looks like a failure, which is why it fails closed instead. A connection with no recorded binding, and a caller that cannot be identified, both proceed, so single-application deployments and master-mode calls made beforeParse.setupare unaffected. The guard becomes unnecessary in 6.0. Parse::Query#results_direct,#count_direct,#distinct_direct,#distinct_direct_pointers, andParse::MongoDB.aggregateacceptclient:alongside the existing auth keywords. It names the authorization context that resolves the call, and it is carried onto theParse::ACLScoperesolution so the binding check above has something to compare. Omitting the keyword resolves throughParse.clientas before.cache_keyspace: trueis the switch for the new cache layout, scoped clearing, invalidation hooks, and shared identity and role planes. Left unset, those cache behaviors are exactly as they were.parse_cache_url:is separately opt-in, and without it no upstream endpoint is contacted.parse_cache_url:must address a different Redis database fromurl:. The two are read and written by different processes with different clearing semantics, and until the scoped-clear fix lands upstream a single_Rolewrite on the shared database destroys the SDK's cache and its create-locks.- Create-locks deliberately keep their historical
parse-stack:foc:v1:prefix and are not relocated into the keyspace. Moving them would have two workers compute different lock keys during a rolling deploy, so they would stop contending on the same key and lose mutual exclusion for the length of the deploy. - The built-in upstream-role integration is compare-only and never changes
permission_stringsor an ACL decision. A caller that directly consumesroles_foras authorization input makes that database part of its trust base, so restrict the credential to+get +pttlon<appId>:role:*. - Webhook-driven invalidation requires the application to expose a webhook endpoint Parse Server can reach and to have registered the hooks. Where it is unregistered or unreachable, the TTL is the only bound on staleness.
Code Example
# The SDK's own cache on database 0, Parse Server's cache read-only on 1.
store = Parse::Cache::Redis.new(
url: "redis://localhost:6379/0",
parse_cache_url: "redis://localhost:6379/1",
)
Parse.setup(
server_url: ENV.fetch("PARSE_SERVER_URL"),
application_id: ENV.fetch("PARSE_APP_ID"),
master_key: ENV.fetch("PARSE_MASTER_KEY"),
cache: store,
expires: 10,
cache_keyspace: true, # reserved keyspace, scoped clearing, hook install
)
# Warns when both URLs resolve to the same Redis database.
store.verify_upstream_isolation!
# Share identity and role resolution across every process. Each client owns
# its own Parse::Authorization::Context, so a second client pointed at a
# second application configures its own view the same way.
view = Parse.client.sdk_cache # the scoped view derived at setup
Parse::Authorization.configure(
identity_cache: view.identity(ttl: 3600),
role_cache: view.roles(ttl: 30),
)
Parse.client.clear_cache! # scoped SCAN, because a keyspace is configured
view.clear(family: :role) # one plane
view.clear(family: :cache, tenant: "acme")Commit: 0991842
Author: Adrian Curtin
Date: August 1, 2026