Skip to content

Commit f6a9f4f

Browse files
committed
refactor: split api key and tier identity caches
1 parent f814d82 commit f6a9f4f

11 files changed

Lines changed: 489 additions & 325 deletions

File tree

apps/api/app/core/dependencies.py

Lines changed: 6 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,22 @@
1-
import hashlib
21
import threading
32
from datetime import timedelta
43
from fnmatch import fnmatch
54
from typing import Any
65

76
import jwt
87
from app.services.auth.api_key_service import APIKeyService
9-
from app.services.rate_limit.identity_cache import identity_cache
108
from fastapi import Depends, Header, Request
119
from jwt import PyJWKClient
1210
from loguru import logger
1311
from sqlalchemy.ext.asyncio import AsyncSession
1412

15-
from shared.core.config import redis_pool_manager, settings
13+
from shared.core.config import settings
1614
from shared.core.database import get_db
1715
from shared.core.exceptions.domain_exceptions import (
1816
AuthException,
1917
PermissionDeniedException,
2018
)
19+
from shared.utils.api_keys import is_api_key_token
2120

2221
# Standard JWKS endpoint path (fixed, following OpenID Connect convention)
2322
JWKS_ENDPOINT_PATH = "/api/auth/jwks"
@@ -184,39 +183,16 @@ async def get_current_user_id(
184183
route_path = _get_route_path(request)
185184

186185
# Mode 1: API Key verification (for external clients)
187-
if token.startswith("sk_"):
188-
# Check identity cache first — skip DB on cache hit
189-
api_key_hash = hashlib.sha256(token.encode()).hexdigest()
190-
try:
191-
cached = await identity_cache.get_cached_identity(
192-
redis_pool_manager.get_redis_service(),
193-
identity_cache._apikey_key(api_key_hash),
194-
)
195-
if cached is not None:
196-
cached_user_id = cached.get("user_id")
197-
cached_user_tier = cached.get("user_tier")
198-
if cached_user_id and isinstance(cached_user_tier, str):
199-
request.state.cached_user_tier = cached_user_tier
200-
request.state.cached_identity_hit = True
201-
request.state.user_id = cached_user_id
202-
_enforce_guest_api_key_scope(route_path, cached_user_tier)
203-
return cached_user_id
204-
except PermissionDeniedException:
205-
raise
206-
except Exception:
207-
pass # Fall through to DB validation
208-
209-
# Cache miss — validate via DB
186+
if is_api_key_token(token):
210187
api_key_service = APIKeyService()
211-
identity = await api_key_service.validate_api_key_identity(db, token)
188+
identity = await api_key_service.get_identity(db, token)
212189
if identity:
213190
request.state.cached_user_tier = identity.user_tier
214-
request.state.cached_identity_hit = False
215191
request.state.user_id = identity.user_id
216192
_enforce_guest_api_key_scope(route_path, identity.user_tier)
217193
return identity.user_id
218-
else:
219-
raise AuthException(user_message="Invalid API Key")
194+
195+
raise AuthException(user_message="Invalid API Key")
220196

221197
# Mode 2: JWT verification (for Dashboard/Internal)
222198
return decode_jwt_token(token)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
"""Redis-backed API-key authentication user cache."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from typing import TYPE_CHECKING
7+
8+
from loguru import logger
9+
10+
if TYPE_CHECKING:
11+
from shared.services.redis.redis_service import RedisService
12+
13+
_API_KEY_MAX_TTL_SECONDS: int = 3600
14+
15+
16+
class APIKeyIdentityCache:
17+
"""Cache validated API-key user IDs by API-key lookup hash."""
18+
19+
@staticmethod
20+
def get_cache_key(api_key_hash: str) -> str:
21+
"""Return the Redis key for an API-key hash."""
22+
return f"identity:apikey:{api_key_hash}"
23+
24+
@staticmethod
25+
def get_reverse_key(user_id: str) -> str:
26+
"""Return the reverse-index Redis key for a user."""
27+
return f"identity:apikeys:{user_id}"
28+
29+
async def get_user_id(
30+
self,
31+
redis: RedisService,
32+
api_key_hash: str,
33+
) -> str | None:
34+
"""Return cached user_id for an API key."""
35+
try:
36+
raw_user_id: object = await redis.get(self.get_cache_key(api_key_hash))
37+
return self._coerce_user_id(raw_user_id)
38+
except Exception:
39+
logger.warning("api_key_identity_cache: failed to read user")
40+
return None
41+
42+
async def set_user_id(
43+
self,
44+
redis: RedisService,
45+
api_key_hash: str,
46+
user_id: str,
47+
ttl_seconds: int,
48+
) -> None:
49+
"""Cache a validated API-key user ID."""
50+
effective_ttl_seconds: int = min(_API_KEY_MAX_TTL_SECONDS, ttl_seconds)
51+
cache_key: str = self.get_cache_key(api_key_hash)
52+
reverse_key: str = self.get_reverse_key(user_id)
53+
54+
try:
55+
await redis.set(cache_key, user_id, ttl=effective_ttl_seconds)
56+
await redis.sadd(reverse_key, api_key_hash)
57+
current_ttl_seconds: int = await redis.ttl(reverse_key)
58+
if (
59+
current_ttl_seconds in (-2, -1)
60+
or current_ttl_seconds < effective_ttl_seconds
61+
):
62+
await redis.expire(reverse_key, effective_ttl_seconds)
63+
except Exception:
64+
logger.warning(
65+
"api_key_identity_cache: failed to set user for user_id={}",
66+
user_id,
67+
)
68+
69+
async def invalidate_api_key(
70+
self,
71+
redis: RedisService,
72+
user_id: str,
73+
api_key_hash: str,
74+
) -> None:
75+
"""Delete one API-key identity cache entry."""
76+
try:
77+
await redis.delete(self.get_cache_key(api_key_hash))
78+
await redis.srem(self.get_reverse_key(user_id), api_key_hash)
79+
except Exception:
80+
logger.warning(
81+
"api_key_identity_cache: failed to invalidate identity for user_id={}",
82+
user_id,
83+
)
84+
85+
async def invalidate_user(
86+
self,
87+
redis: RedisService,
88+
user_id: str,
89+
) -> None:
90+
"""Delete all API-key identity cache entries for a user."""
91+
try:
92+
reverse_key: str = self.get_reverse_key(user_id)
93+
api_key_hashes: set[object] = await redis.smembers(reverse_key)
94+
for api_key_hash in api_key_hashes:
95+
await redis.delete(self.get_cache_key(str(api_key_hash)))
96+
await redis.delete(reverse_key)
97+
except Exception:
98+
logger.warning(
99+
"api_key_identity_cache: failed to invalidate user_id={}",
100+
user_id,
101+
)
102+
103+
def _coerce_user_id(self, raw_user_id: object) -> str | None:
104+
"""Return a typed user ID from current or legacy Redis values."""
105+
if isinstance(raw_user_id, str):
106+
try:
107+
parsed_user_id: object = json.loads(raw_user_id)
108+
except json.JSONDecodeError:
109+
return raw_user_id
110+
else:
111+
parsed_user_id = raw_user_id
112+
113+
if isinstance(parsed_user_id, str):
114+
return parsed_user_id
115+
116+
if isinstance(parsed_user_id, dict):
117+
legacy_user_id: object = parsed_user_id.get("user_id")
118+
if isinstance(legacy_user_id, str):
119+
return legacy_user_id
120+
121+
return None
122+
123+
124+
api_key_identity_cache = APIKeyIdentityCache()

0 commit comments

Comments
 (0)