Skip to content

Commit 2a0e712

Browse files
committed
Evict user info cache entries when roles change
1 parent 783b0e4 commit 2a0e712

3 files changed

Lines changed: 47 additions & 13 deletions

File tree

api/core/security.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,10 @@
1717
# Set up logger for this module
1818
logger = get_logger(__name__)
1919

20-
# TTL cache for token validation (1 hour TTL, max 1000 entries)
21-
_token_cache: cachetools.TTLCache[str, "UserInfo"] = cachetools.TTLCache(
20+
# TTL cache keyed by a user's OIDC subject. Evict entries when roles change. We
21+
# still validate the JWT signature and expiry on every request before reading a
22+
# cached record.
23+
_user_info_cache: cachetools.TTLCache[str, "UserInfo"] = cachetools.TTLCache(
2224
maxsize=1000, ttl=60 * 60
2325
)
2426

@@ -41,6 +43,17 @@ async def close_tdei_client() -> None:
4143
_tdei_client = None
4244

4345

46+
def evict_user_from_cache(auth_uid: str) -> None:
47+
"""
48+
Evict a user's cached UserInfo object so that their next request re-fetches
49+
permissions.
50+
51+
Call this after modifying a user's roles in the OSM DB to ensure the change
52+
takes effect on their next request rather than after the cache TTL expires.
53+
"""
54+
_user_info_cache.pop(auth_uid, None)
55+
56+
4457
security = HTTPBearer()
4558

4659

@@ -72,6 +85,7 @@ class UserInfo:
7285
credentials: str
7386
user_uuid: UUID
7487
user_name: str
88+
token_jti: str # JWT ID used to detect token rotation on cache hits
7589

7690
# workspaceId, role from OSM DB
7791
osmWorkspaceRoles: dict[int, list[WorkspaceUserRoleType]]
@@ -151,9 +165,13 @@ async def validate_token(
151165
osm_db_session: AsyncSession = Depends(get_osm_db_session),
152166
task_db_session: AsyncSession = Depends(get_task_db_session),
153167
) -> UserInfo:
154-
"""Dependency to get current authenticated user from TDEI/KeyCloak token and APIs.
168+
"""
169+
Dependency that gets the current authenticated user from the TDEI/KeyCloak
170+
access token and fetches permissions from TDEI APIs.
155171
156-
Results are cached by token for 1 hour to avoid repeated validation calls.
172+
We validate the JWT's signature and expiry on every request. The expensive
173+
TDEI API and DB lookups are cached for 1 hour and should be evicted when a
174+
user's role changes via evict_user_from_cache().
157175
"""
158176
token = credentials.credentials
159177

@@ -172,16 +190,23 @@ async def validate_token(
172190
if user_id is None:
173191
raise credentials_exception
174192

175-
# Check cache first
176-
if token in _token_cache:
177-
logger.info("Token validation cache hit")
178-
return _token_cache[token]
179-
180-
# Cache miss - perform full validation
193+
# Cache keyed by user ID. If the token rotated (new "jti") since we created
194+
# the cache entry, evict it so we fetch fresh claims:
195+
#
196+
if user_id in _user_info_cache:
197+
cached = _user_info_cache[user_id]
198+
current_jti = payload.get("jti", "")
199+
if cached.token_jti == current_jti:
200+
logger.info("Token validation cache hit")
201+
return cached
202+
logger.info("Token validation cache miss: token rotated")
203+
del _user_info_cache[user_id]
204+
205+
# Cache miss: fetch TDEI roles and DB data:
181206
user_info = await _validate_token_uncached(
182207
token, user_id, payload, osm_db_session, task_db_session
183208
)
184-
_token_cache[token] = user_info
209+
_user_info_cache[user_id] = user_info
185210

186211
return user_info
187212

@@ -214,6 +239,7 @@ async def _validate_token_uncached(
214239
raise credentials_exception from None
215240

216241
r.credentials = token
242+
r.token_jti = payload.get("jti", "")
217243
r.user_name = payload.get("preferred_username", "unknown")
218244

219245
# get user's project groups and roles from TDEI

api/src/users/routes.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from sqlmodel.ext.asyncio.session import AsyncSession
55

66
from api.core.database import get_osm_session, get_task_session
7-
from api.core.security import UserInfo, validate_token
7+
from api.core.security import UserInfo, evict_user_from_cache, validate_token
88
from api.src.users.repository import UserRepository
99
from api.src.users.schemas import SetRoleRequest, WorkspaceUserRoleItem
1010
from api.src.workspaces.repository import WorkspaceRepository
@@ -62,6 +62,7 @@ async def assign_member_role(
6262
await workspace_repo.getById(current_user, workspace_id)
6363

6464
await user_repo.assign_member_role(workspace_id, user_id, body.role)
65+
evict_user_from_cache(str(user_id))
6566

6667

6768
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -78,3 +79,4 @@ async def remove_member_role(
7879
)
7980

8081
await user_repo.remove_member_role(workspace_id, user_id)
82+
evict_user_from_cache(str(user_id))

api/src/workspaces/routes.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
from api.core.database import get_osm_session, get_task_session
77
from api.core.logging import get_logger
8-
from api.core.security import UserInfo, validate_token
8+
from api.core.security import UserInfo, evict_user_from_cache, validate_token
99
from api.src.users.repository import UserRepository
1010
from api.src.users.schemas import WorkspaceUserRoleType
1111
from api.src.workspaces.repository import OSMRepository, WorkspaceRepository
@@ -148,6 +148,12 @@ async def create_workspace(
148148
WorkspaceUserRoleType.LEAD,
149149
)
150150

151+
# Evict the creator's cache so their next request reflects the new
152+
# workspace and lead role rather than serving stale data for up to
153+
# an hour:
154+
#
155+
evict_user_from_cache(str(current_user.user_uuid))
156+
151157
return workspace
152158
except Exception as e:
153159
logger.error(f"Failed to create workspace: {str(e)}")

0 commit comments

Comments
 (0)