"""Redis caching for entity permission visibility. Extracted from entity_permission_service.py for modularity. """ from __future__ import annotations import json import logging import uuid import redis.asyncio as aioredis from sqlalchemy.ext.asyncio import AsyncSession from app.services.permission_resolver import get_visible_ids logger = logging.getLogger(__name__) CACHE_TTL = 300 # 5 minutes CACHE_PREFIX = "ep_vis" # entity permission visibility async def _invalidate_user_cache( redis: aioredis.Redis | None, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, ) -> None: """Invalidate the visibility cache for a user + entity type.""" if redis is None: return cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}:{entity_type}" await redis.delete(cache_key) async def get_cached_visible_ids( db: AsyncSession, redis: aioredis.Redis, tenant_id: uuid.UUID, user_id: uuid.UUID, entity_type: str, ) -> tuple[set[uuid.UUID], dict[uuid.UUID, str]]: """Get visible IDs from Redis cache or resolve from DB. Cache key: ep_vis:{user_id}:{tenant_id}:{entity_type} Cache value: JSON {visible_ids: [str], access_map: {str: str}, version: int} TTL: 5 minutes Validates cached permission_version against current DB version. If they differ, the cache entry is stale and will be re-resolved. """ cache_key = f"{CACHE_PREFIX}:{user_id}:{tenant_id}:{entity_type}" raw = await redis.get(cache_key) if raw is not None: data = json.loads(raw) cached_version = data.get("version", -1) # Validate cached version against current DB version try: from app.core.permissions import _get_current_permission_version current_version = await _get_current_permission_version(db, user_id, tenant_id) except Exception: logger.warning( "Failed to query permission_version for visible_ids cache validation " "(user=%s, tenant=%s) — invalidating cache and re-resolving", user_id, tenant_id, exc_info=True, ) await redis.delete(cache_key) # Fall through to re-resolution below else: if cached_version == current_version: visible = {uuid.UUID(eid) for eid in data.get("visible_ids", [])} access_map = {uuid.UUID(eid): level for eid, level in data.get("access_map", {}).items()} return visible, access_map logger.info( "visible_ids cache version mismatch for user=%s tenant=%s entity_type=%s " "(cached=%s, current=%s) — re-resolving", user_id, tenant_id, entity_type, cached_version, current_version, ) await redis.delete(cache_key) # Cache miss, stale, or version mismatch — resolve from DB visible, access_map = await get_visible_ids(db, tenant_id, user_id, entity_type) # Get current permission version for cache stamping try: from app.core.permissions import _get_current_permission_version current_version = await _get_current_permission_version(db, user_id, tenant_id) except Exception: logger.warning("Failed to get permission_version for cache stamping — using 0") current_version = 0 # Store in cache cache_data = { "visible_ids": [str(eid) for eid in visible], "access_map": {str(eid): level for eid, level in access_map.items()}, "version": current_version, } await redis.setex(cache_key, CACHE_TTL, json.dumps(cache_data)) return visible, access_map async def invalidate_all_user_entity_cache( redis: aioredis.Redis, tenant_id: uuid.UUID, user_id: uuid.UUID, ) -> None: """Invalidate all entity permission caches for a user.""" pattern = f"{CACHE_PREFIX}:{user_id}:{tenant_id}:*" batch_size = 200 cursor: int | bytes | str = 0 while True: cursor, keys = await redis.scan(cursor=cursor, match=pattern, count=batch_size) if keys: await redis.delete(*keys) if int(cursor) == 0: break