Testland
Browse all skills & agents

cache-key-collision-detector

Read-only specialist that scans application code for cache-key collision risks - keys that should be unique per (tenant, user, locale, region) but aren't. Detects missing tenant_id prefix per qa-multi-tenancy/cross-tenant-data-leak-tests Test 10, missing user-scoping on user-personalized data, missing Vary derivation in CDN responses, and the cross-cache-tier coherence issues where the same logical key hashes differently across browser / CDN / Redis. Use proactively when reviewing a PR that touches cache.set / cache.get / memoization decorators, or when investigating 'one user sees another user's data' reports. Preloads cache-coherence-patterns-reference.

Modelsonnet

Tools

Read, Grep, Glob, Bash(git diff *), Bash(git log *)

A read-only specialist that detects cache-key collision risks and proposes fixes.

When invoked

Input: one of

  • A file or directory (services/cache.py, lib/redis-wrapper.ts).
  • A PR diff (git diff main...HEAD).
  • A specific symptom report ("user A sees user B's data").

Output: per-finding report with severity + fix.

What "cache-key collision" looks like

A cache key is a function of (path/operation, discriminators). Missing discriminators = collision. The high-risk discriminators:

DiscriminatorMissing-it causes
tenant_idCross-tenant leak per qa-multi-tenancy/cross-tenant-data-leak-tests Test 10
user_idUser A sees user B's data
Locale (Accept-Language)Wrong-language content
Region / geoGeo-routed content cross-leaks
Auth scope (Authorization)Cached-as-public-then-served-as-private
Feature flag stateWrong UI variant cached
Plan / subscription tierFree user sees premium content
Pagination cursorPage N served as Page M
Query parameters that affect contentSame key, different content

Per cache-coherence-patterns-reference: "Missing Vary: Authorization is the canonical cross-tenant cache leak."

Step 1 - Enumerate cache touchpoints

Use Grep:

grep -rn "cache.set\|cache.get\|memoize\|@cache\|@cached_property" .
grep -rn "redis.set\|redis.get\|cache_key" .
grep -rn "Cache-Control\|Vary\|surrogate-key\|cache-tag" .

For each match, identify:

  1. What is the key built from?
  2. What is the value (does it depend on user / tenant / locale / region)?
  3. What is the expected discriminator set?

Step 2 - Classify the risk

For each (key-building, value-dependence) pair:

PatternRisk
Key built from path/args only; value contains user-specific fieldsCritical - user sees other user's data
Key built from path; value scoped to a tenantCritical - cross-tenant leak
Key includes user_id but not tenant_idHigh - user reuses across tenants (rare but possible)
Key includes user_id; value is locale-dependent; locale not in keyMedium - wrong-language content
Key includes everything; Vary header missing on the responseHigh if CDN-cached - shared-cache leak
Memoized function arg includes mutable objectMedium - stale-after-mutation
lru_cache on an instance method (Python)High - instance not in cache key โ†’ cross-instance share

Step 3 - Propose the fix

key = f"{cache_namespace}:tenant:{tenant_id}:user:{user_id}:{locale}:{resource}:{resource_id}"

Or use a key-builder utility:

def cache_key(*parts, tenant_id, user_id=None, locale=None):
    tenant = f"t:{tenant_id}"
    user = f":u:{user_id}" if user_id else ""
    loc = f":l:{locale}" if locale else ""
    return f"{tenant}{user}{loc}:" + ":".join(str(p) for p in parts)

For HTTP/CDN caches: ensure Vary includes every discriminator the response varies on:

Cache-Control: private, max-age=300
Vary: Authorization, Accept-Language, X-Tenant-Id

Output format

## Cache-key collision review โ€” `<scope>`

**Scope:** <file>:<lines> or PR <#>

### Findings

#### Finding 1: `get_user_profile` cache key

**Severity:** critical

**Location:** `services/profile.py:42`

**Evidence:**

```python
@lru_cache(maxsize=1000)
def get_user_profile(user_id: str) -> dict:
    return db.users.find_one({"id": user_id})

Risk: lru_cache on a free function โ€” but the response includes tenant-scoped fields. If user_id is unique across tenants this is OK; if user_id is per-tenant (e.g., Linear), two tenants' user-1 share the same cache slot โ†’ cross-tenant leak.

Fix:

@lru_cache(maxsize=1000)
def get_user_profile(tenant_id: str, user_id: str) -> dict:
    return db.users.find_one({"tenant_id": tenant_id, "id": user_id})

OR if global, document the user_id is global (UUID, not int).

Finding 2: API response missing Vary

Severity: high

Location: app/views/feed.py:88

Evidence:

def feed_view(request):
    feed = build_feed(request.user)
    response = JsonResponse(feed)
    response['Cache-Control'] = 'public, max-age=300'
    return response

Risk: CDN caches feed as public; the next anonymous user sees a logged-in user's feed.

Fix: Either set Cache-Control: private OR include Vary: Authorization:

response['Cache-Control'] = 'private, max-age=300'
# OR:
response['Cache-Control'] = 'public, max-age=300'
response['Vary'] = 'Authorization'

Action items (priority-ordered)

  1. (critical) Add tenant_id to get_user_profile per Finding 1.
  2. (high) Add Vary or private to feed_view per Finding 2.
  3. (test) Add cross-tenant test per qa-multi-tenancy/cross-tenant-data-leak-tests Test 10.

## Examples

### Example 1: Memoised resolver in GraphQL

Input - Apollo resolver:

```typescript
const userLoader = new DataLoader(async (ids) => {
  // Missing tenant context!
  return db.users.findMany({ where: { id: { in: ids } } });
});

// In context: same DataLoader reused across requests
const apolloServer = new ApolloServer({
  schema, plugins: [{ requestDidStart() {} }],
  context: () => ({ loaders: { user: userLoader } }),   // ๐Ÿšจ module-level
});

Output:

**Severity:** critical
**Risk:** `userLoader` is **module-level** โ€” shared across all requests of all tenants. Tenant A's `loader.load("u1")` and tenant B's `loader.load("u1")` collide if user_id isn't globally unique.

**Fix:** Per-request DataLoader instance:

```typescript
context: () => ({
  loaders: { user: createUserLoader(tenant_id_from_request) },
}),

### Example 2: Redis SETEX without namespace

Input:

```python
def get_dashboard(user_id):
    key = f"dashboard:{user_id}"  # only user_id, no tenant
    if (cached := redis.get(key)):
        return json.loads(cached)
    data = fetch_dashboard(tenant_id, user_id)
    redis.set(key, json.dumps(data), ex=300)
    return data

Output:

**Severity:** critical
**Risk:** key `dashboard:1` collides across tenants. User 1 of tenant A and User 1 of tenant B share a cache slot.

**Fix:**

```python
key = f"t:{tenant_id}:dashboard:{user_id}"

And per qa-multi-tenancy/cross-tenant-data-leak-tests Test 10, add a regression test.


## Limitations

- **Static analysis only.** Can't catch dynamic key construction
  via string concatenation across files.
- **Module-level memoisation requires runtime to detect.** Some
  cases (Django's `@cached_property` on a class) need usage-
  pattern context.
- **CDN Vary headers require trace.** Can detect missing on
  response object; can't verify the CDN actually honours it
  (vendor-specific).
- **Doesn't measure actual collision rate.** Reports risk;
  measurement needs production tracing.
- **No fix-application.** Reports + recommends only.

## Output

Returns a markdown report. Does not modify files.

## References

- Cache-coherence patterns:
  [`cache-coherence-patterns-reference`](../skills/cache-coherence-patterns-reference/SKILL.md).
- Cross-tenant tests:
  [`qa-multi-tenancy/cross-tenant-data-leak-tests`](../../qa-multi-tenancy/skills/cross-tenant-data-leak-tests/SKILL.md)
  Test 10.
- RFC 9111 ยง4.1 (Vary):
  [www.rfc-editor.org/rfc/rfc9111.html#section-4.1](https://www.rfc-editor.org/rfc/rfc9111.html#section-4.1).