Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions src/fides/api/models/manual_task/manual_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,8 +75,6 @@ class ManualTaskReferenceType(StrEnum):
# Add more reference types as needed




class ManualTaskConfigurationType(StrEnum):
"""Enum for manual task configuration types."""

Expand Down
214 changes: 214 additions & 0 deletions src/fides/api/oauth/client_cache.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
"""
In-memory TTL cache for OAuth client details.

This module provides a simple cache for ClientDetail objects to reduce
database queries during authentication. Entries automatically expire
after a configurable TTL (time-to-live).
"""

from __future__ import annotations

from dataclasses import dataclass
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing timezone import from datetime module. The suggestion to use datetime.now(timezone.utc) requires this import.

Suggested change
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone

from datetime import datetime, timedelta, timezone
from typing import Dict, Optional

from loguru import logger
from sqlalchemy.orm import Session

from fides.api.models.client import ClientDetail
from fides.config import CONFIG, FidesConfig

# Maximum number of clients to cache to prevent unbounded memory growth
MAX_CACHE_SIZE = 1000


@dataclass
class CacheEntry:
"""A cached client with its expiration timestamp."""

client: ClientDetail
expires_at: datetime
Comment on lines +29 to +30
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cached ClientDetail objects are mutable SQLAlchemy ORM instances with lazy-loaded relationships. If the cached object's client.user.password_reset_at is accessed after being cached, it could trigger a lazy-load database query using a stale or closed database session, causing errors. The cached object may also become detached from its session.

Consider either:

  1. Eagerly loading required relationships before caching
  2. Serializing/deserializing the client data instead of caching ORM objects
  3. Documenting that cached objects should not access relationships



class ClientCache:
"""
TTL cache for ClientDetail objects.

This cache stores ClientDetail objects keyed by client_id and automatically
considers entries expired after the configured TTL. The cache has a maximum
size limit to prevent unbounded memory growth.
"""

def __init__(self, max_size: int = MAX_CACHE_SIZE):
self._cache: Dict[str, CacheEntry] = {}
self._max_size = max_size
Comment on lines +33 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thread safety concern: The global _client_cache instance is not thread-safe. Dictionary operations in Python are not atomic, and concurrent reads/writes from multiple threads (common in FastAPI with uvicorn workers) can cause race conditions. Consider using threading.Lock() for critical sections or using a thread-safe data structure.

Example fix:

import threading

class ClientCache:
    def __init__(self, max_size: int = MAX_CACHE_SIZE):
        self._cache: Dict[str, CacheEntry] = {}
        self._max_size = max_size
        self._lock = threading.Lock()
    
    def get(self, client_id: str) -> Optional[ClientDetail]:
        with self._lock:
            # existing logic


def get(self, client_id: str) -> Optional[ClientDetail]:
"""
Retrieve a client from the cache if it exists and hasn't expired.

Args:
client_id: The client ID to look up.

Returns:
The cached ClientDetail if found and not expired, None otherwise.
"""
# Cache disabled
if not self.is_enabled():
return None

entry = self._cache.get(client_id)
if entry is None:
return None

# Check if entry has expired
if datetime.now(timezone.utc) >= entry.expires_at:
# Entry expired, remove it
self._cache.pop(client_id, None)
return None

return entry.client

def set(self, client_id: str, client: ClientDetail) -> None:
"""
Store a client in the cache.

If the cache is at maximum capacity, the oldest entries are evicted
to make room for the new entry.

Args:
client_id: The client ID to use as the cache key.
client: The ClientDetail object to cache.
"""
# Cache disabled
if not self.is_enabled():
return

ttl_seconds = CONFIG.security.oauth_client_cache_ttl_seconds

# Evict an entry if at capacity
if len(self._cache) >= self._max_size and client_id not in self._cache:
self._evict_one()

# Eagerly load the user relationship before caching to avoid
# lazy-loading issues with detached SQLAlchemy objects
_ = client.user

expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
self._cache[client_id] = CacheEntry(client=client, expires_at=expires_at)

def delete(self, client_id: str) -> bool:
"""
Remove a specific client from the cache.

Args:
client_id: The client ID to remove.

Returns:
True if the entry was removed, False if it wasn't in the cache.
"""
removed = self._cache.pop(client_id, None)
if removed is not None:
logger.debug("Removed client_id={} from cache", client_id)
return True
return False

def clear(self) -> int:
"""
Clear all entries from the cache.

Returns:
The number of entries that were cleared.
"""
count = len(self._cache)
self._cache.clear()
logger.debug("Cleared {} entries from client cache", count)
return count

def _evict_one(self) -> None:
"""Evict an arbitrary entry from the cache"""
try:
# Pop an arbitrary key
self._cache.pop(next(iter(self._cache)))
except (StopIteration, KeyError):
# Cache is empty or was modified concurrently
pass
Comment on lines +128 to +135
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Eviction strategy is arbitrary and unpredictable. Using next(iter(self._cache)) doesn't guarantee removal of the oldest or least-recently-used entry. In Python 3.7+, dicts maintain insertion order, but this doesn't account for access patterns. Consider implementing a proper LRU (Least Recently Used) eviction policy for better cache performance.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!


def size(self) -> int:
"""Return the current number of entries in the cache."""
return len(self._cache)

def is_enabled(self) -> bool:
"""Return True if caching is enabled (TTL > 0)."""
return CONFIG.security.oauth_client_cache_ttl_seconds > 0


# Global cache instance
_client_cache = ClientCache()
Comment on lines +146 to +147
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Multi-worker concern: In production deployments with multiple uvicorn workers or multiple server instances, each process will have its own separate in-memory cache. This means:

  1. Cache invalidation won't propagate across workers
  2. Different workers may serve requests with different cached data
  3. When client permissions/scopes change, some requests may use stale data for up to 5 minutes depending on which worker handles them

The PR description mentions "we will handle cache invalidations in a follow up PR", but this architectural limitation should be documented or addressed with a distributed cache (Redis) instead.



def get_cached_client(
db: Session,
client_id: str,
config: FidesConfig,
scopes: list[str],
roles: list[str],
) -> Optional[ClientDetail]:
"""
Get a client from cache or database.

This function first checks the cache for the client. If not found (or expired),
it queries the database and caches the result.

Note: Root client is not cached as it's already an in-memory object that
doesn't require a database query.

Args:
db: Database session for querying if cache miss.
client_id: The client ID to look up.
config: Fides configuration object.
scopes: Scopes to pass to ClientDetail.get (only used for root client).
roles: Roles to pass to ClientDetail.get (only used for root client).

Returns:
The ClientDetail if found, None otherwise.
"""
# Root client is always created in-memory, no caching needed
if client_id == config.security.oauth_root_client_id:
return ClientDetail.get(
db,
object_id=client_id,
config=config,
scopes=scopes,
roles=roles,
)

# Try cache first
cached_client = _client_cache.get(client_id)
if cached_client is not None:
return cached_client

# Cache miss - query database
client = ClientDetail.get(
db,
object_id=client_id,
config=config,
scopes=scopes,
roles=roles,
)

# Cache the result if found
if client is not None:
_client_cache.set(client_id, client)

Comment on lines +201 to +203
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Only caches found clients. If a client lookup returns None (client doesn't exist), the cache doesn't remember this. Every request for a non-existent client will hit the database. Consider caching negative results (with a shorter TTL) to prevent potential DoS attacks where an attacker repeatedly requests invalid client IDs.

return client


def clear_client_cache() -> int:
"""
Clear all entries from the client cache.

Returns:
The number of entries that were cleared.
"""
return _client_cache.clear()
8 changes: 5 additions & 3 deletions src/fides/api/oauth/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from fides.api.models.policy import PolicyPreWebhook
from fides.api.models.pre_approval_webhook import PreApprovalWebhook
from fides.api.models.privacy_request import RequestTask
from fides.api.oauth.client_cache import get_cached_client
from fides.api.oauth.roles import ROLES_TO_SCOPES_MAPPING, get_scopes_from_roles
from fides.api.request_context import set_user_id
from fides.api.schemas.external_https import (
Expand Down Expand Up @@ -406,9 +407,10 @@ def extract_token_and_load_client(
raise AuthorizationError(detail="Not Authorized for this action")

# scopes/roles param is only used if client is root client, otherwise we use the client's associated scopes
client = ClientDetail.get(
db,
object_id=client_id,
# Use cached client lookup to reduce database queries on authenticated requests
client = get_cached_client(
db=db,
client_id=client_id,
config=CONFIG,
scopes=CONFIG.security.root_user_scopes,
roles=CONFIG.security.root_user_roles,
Expand Down
4 changes: 4 additions & 0 deletions src/fides/config/security_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,10 @@ class SecuritySettings(FidesSettings):
default=16,
description="Sets desired length in bytes of generated client secret used for oauth.",
)
oauth_client_cache_ttl_seconds: int = Field(
default=300,
description="The time in seconds for which OAuth client details will be cached in memory. Set to 0 to disable caching. Default is 300 seconds (5 minutes).",
)
parent_server_password: Optional[str] = Field(
default=None,
description="When using a parent/child Fides deployment, this password will be used by the child server to access the parent server.",
Expand Down
Loading
Loading