Skip to content

Commit 583ad8d

Browse files
committed
Evict user info cache entries when roles change
1 parent e4c848f commit 583ad8d

3 files changed

Lines changed: 61 additions & 23 deletions

File tree

api/core/security.py

Lines changed: 47 additions & 21 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[UUID, "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: UUID) -> 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

@@ -168,27 +186,39 @@ async def validate_token(
168186
except Exception:
169187
raise credentials_exception
170188

171-
user_id: str | None = payload.get("sub")
172-
if user_id is None:
189+
user_id_str: str | None = payload.get("sub")
190+
if user_id_str 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]
193+
try:
194+
user_uuid = UUID(user_id_str)
195+
except ValueError:
196+
raise credentials_exception from None
179197

180-
# Cache miss - perform full validation
198+
# Cache keyed by user UUID. If the token rotated (new "jti") since we
199+
# created the cache entry, evict it so we fetch fresh claims:
200+
#
201+
if user_uuid in _user_info_cache:
202+
cached = _user_info_cache[user_uuid]
203+
current_jti = payload.get("jti", "")
204+
if cached.token_jti == current_jti:
205+
logger.info("Token validation cache hit")
206+
return cached
207+
logger.info("Token validation cache miss: token rotated")
208+
del _user_info_cache[user_uuid]
209+
210+
# Cache miss: fetch TDEI roles and DB data:
181211
user_info = await _validate_token_uncached(
182-
token, user_id, payload, osm_db_session, task_db_session
212+
token, user_uuid, payload, osm_db_session, task_db_session
183213
)
184-
_token_cache[token] = user_info
214+
_user_info_cache[user_uuid] = user_info
185215

186216
return user_info
187217

188218

189219
async def _validate_token_uncached(
190220
token: str,
191-
user_id: str,
221+
user_uuid: UUID,
192222
payload: dict,
193223
osm_db_session: AsyncSession,
194224
task_db_session: AsyncSession,
@@ -207,21 +237,17 @@ async def _validate_token_uncached(
207237
}
208238

209239
r = UserInfo()
210-
211-
try:
212-
r.user_uuid = UUID(user_id)
213-
except ValueError:
214-
raise credentials_exception from None
215-
240+
r.user_uuid = user_uuid
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
220246
pgs = []
221247

222248
try:
223249
response = await _tdei_client.get(
224-
f"project-group-roles/{user_id}",
250+
f"project-group-roles/{user_uuid}",
225251
headers=headers,
226252
params={"page_no": 1, "page_size": 1000},
227253
)

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(user_id)
6566

6667

6768
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
@@ -85,3 +86,4 @@ async def remove_member_role(
8586
await workspace_repo.getById(current_user, workspace_id)
8687

8788
await user_repo.remove_member_role(workspace_id, user_id)
89+
evict_user_from_cache(user_id)

api/src/workspaces/routes.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
from typing import Any
2+
from uuid import UUID
23

34
from fastapi import APIRouter, Depends, HTTPException, status
45
from sqlmodel.ext.asyncio.session import AsyncSession
56

67
from api.core.database import get_osm_session, get_task_session
78
from api.core.logging import get_logger
8-
from api.core.security import UserInfo, validate_token
9+
from api.core.security import UserInfo, evict_user_from_cache, validate_token
910
from api.src.users.repository import UserRepository
1011
from api.src.users.schemas import WorkspaceUserRoleType
1112
from api.src.workspaces.repository import OSMRepository, WorkspaceRepository
@@ -148,6 +149,12 @@ async def create_workspace(
148149
WorkspaceUserRoleType.LEAD,
149150
)
150151

152+
# Evict the creator's cache so their next request reflects the new
153+
# workspace and lead role rather than serving stale data for up to
154+
# an hour:
155+
#
156+
evict_user_from_cache(current_user.user_uuid)
157+
151158
return workspace
152159
except Exception as e:
153160
logger.error(f"Failed to create workspace: {str(e)}")
@@ -193,8 +200,11 @@ async def delete_workspace(
193200
)
194201

195202
try:
203+
members = await repository_users.get_privileged_workspace_members(workspace_id)
196204
await repository_ws.delete(current_user, workspace_id)
197205
await repository_users.remove_all_member_roles(workspace_id)
206+
for member in members:
207+
evict_user_from_cache(UUID(member.auth_uid))
198208
except Exception as e:
199209
logger.error(f"Failed to delete workspace {workspace_id}: {str(e)}")
200210
raise

0 commit comments

Comments
 (0)