From 40b035deb1e3bf587e590516dd757b5d3fa386ad Mon Sep 17 00:00:00 2001 From: borhanst Date: Fri, 21 Aug 2026 11:09:23 +0600 Subject: [PATCH 01/12] feat: add optional Redis-backed caching and rate limiting - Updated installation requirements to include `fastapi-redis-sdk` for Redis support. - Introduced `CacheConfig` for managing caching and rate limiting configurations. - Implemented Redis-backed login rate limiting with graceful degradation to in-memory fallback. - Added Redis caching for list and detail endpoints, including automatic cache headers. - Enhanced documentation to cover new Redis features and usage. - Created tests for Redis caching and rate limiting functionalities. --- .github/workflows/tests.yml | 2 +- docs/guide/features.md | 2 + docs/guide/redis-caching.md | 74 ++++++ fastapi_admin_kit/admin/admin_config.py | 4 + fastapi_admin_kit/admin/core.py | 66 +++++- fastapi_admin_kit/auth/views.py | 24 +- fastapi_admin_kit/config/__init__.py | 2 + fastapi_admin_kit/config/cache.py | 137 ++++++++++++ fastapi_admin_kit/modeladmin.py | 6 + fastapi_admin_kit/redis.py | 285 ++++++++++++++++++++++++ fastapi_admin_kit/router.py | 37 ++- mkdocs.yml | 1 + pyproject.toml | 4 + tests/test_redis_cache.py | 249 +++++++++++++++++++++ 14 files changed, 874 insertions(+), 19 deletions(-) create mode 100644 docs/guide/redis-caching.md create mode 100644 fastapi_admin_kit/config/cache.py create mode 100644 fastapi_admin_kit/redis.py create mode 100644 tests/test_redis_cache.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d54205f..e4842f8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -21,7 +21,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pip install -e ".[dev,ai]" + run: pip install -e ".[dev,ai,redis]" - name: Lint with ruff run: | diff --git a/docs/guide/features.md b/docs/guide/features.md index 32099c1..df1b8e7 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -32,6 +32,8 @@ Everything FastAPI Admin Kit offers, in one place. | TOTP Two-Factor Auth | QR code setup, enable/disable, backup codes via `/admin/profile/2fa` | [Auth & RBAC](auth-rbac.md) | | CSRF Protection | Signed CSRF tokens on all state-changing requests | [Auth & RBAC](auth-rbac.md) | | Rate Limiting | Sliding-window rate limiter on authentication endpoints | [Auth & RBAC](auth-rbac.md) | +| Redis Rate Limiting | Distributed Redis-backed login rate limiting with in-memory fallback | [Redis & Caching](redis-caching.md) | +| Redis Response Caching | Opt-in Redis-backed cache for list/detail endpoints with `X-Redis-Cache` headers | [Redis & Caching](redis-caching.md) | | Password Hashing | bcrypt with configurable rounds | [Auth & RBAC](auth-rbac.md) | | Session Security | Signed cookies with `SameSite=Strict`, `Secure`, and configurable TTL | [Configuration](../getting-started/configuration.md) | | Secret Key Validation | Enforces minimum 32-character secret key at startup | [Configuration](../getting-started/configuration.md) | diff --git a/docs/guide/redis-caching.md b/docs/guide/redis-caching.md new file mode 100644 index 0000000..0345697 --- /dev/null +++ b/docs/guide/redis-caching.md @@ -0,0 +1,74 @@ +# Redis & Caching + +Optional Redis-backed rate limiting and response caching, built on +[`fastapi-redis-sdk`](https://pypi.org/project/fastapi-redis-sdk/). Both +features degrade gracefully — without Redis the admin behaves exactly as +before. + +## Install + +Redis support is an optional extra: + +```bash +pip install fastapi-admin-kit[redis] +``` + +## Enabling Redis + +Set `REDIS_URL` in your environment. That alone switches the login rate +limiter to the distributed Redis backend: + +```dotenv +REDIS_URL=redis://localhost:6379/0 +``` + +No Redis? No problem. The admin falls back to the existing in-memory +sliding-window rate limiter, keeping full backward compatibility. + +## Redis-backed login rate limiting + +When `REDIS_URL` is set (and the SDK is installed) the login endpoint uses a +distributed `RateLimitBackend` so the attempt counters hold across every +worker or pod. Defaults are 5 attempts per 900-second window. + +The Redis backend fails open: if Redis becomes unreachable the admin logs the +failure and allows the request rather than taking login down. With no +`REDIS_URL` the existing in-memory limiter is used. + +## Opt-in response caching + +Caching is **off by default**. Enable it per-admin or globally: + +```dotenv +# Global defaults (also settable per Admin) +FASTAPI_ADMIN_KIT_CACHE_ENABLED=false +FASTAPI_ADMIN_KIT_CACHE_TTL=300 +``` + +Programmatically: + +```python +admin = Admin( + app=app, + engine=engine, + secret_key="...", + cache_enabled=True, + cache_ttl=120, # seconds; explicit value wins over the env default +) +``` + +When caching is active, the HTML list and detail GET endpoints for every +registered model get a Redis-backed `cache()` dependency keyed on an eviction +group per model table. Responses automatically carry +`X-Redis-Cache: HIT` / `X-Redis-Cache: MISS` headers. + +Write operations invalidate naturally via eviction groups scoped to each +model's table, and `cache_ttl=0` disables expiration. + +## Configuration reference + +| Env var | Default | Purpose | +|---------|---------|---------| +| `REDIS_URL` | *(unset)* | Enables Redis-backed rate limiting and caching when set | +| `FASTAPI_ADMIN_KIT_CACHE_ENABLED` | `false` | Global opt-in switch for response caching | +| `FASTAPI_ADMIN_KIT_CACHE_TTL` | `300` | Default cache TTL in seconds | diff --git a/fastapi_admin_kit/admin/admin_config.py b/fastapi_admin_kit/admin/admin_config.py index 698cb16..9bf68f3 100644 --- a/fastapi_admin_kit/admin/admin_config.py +++ b/fastapi_admin_kit/admin/admin_config.py @@ -7,6 +7,7 @@ AuditConfig, AuthConfig, BehaviorConfig, + CacheConfig, NavConfig, StorageConfig, UIConfig, @@ -29,6 +30,7 @@ def __init__( nav: NavConfig | None = None, template_dirs: list[str] | None = None, ai_chat: AIChatConfig | None = None, + cache: CacheConfig | None = None, ): self.ui = ui or UIConfig() self.auth = auth or AuthConfig() @@ -38,6 +40,7 @@ def __init__( self.nav = nav or NavConfig() self.template_dirs = template_dirs or [] self.ai_chat = ai_chat or AIChatConfig() + self.cache = cache or CacheConfig() def validate_all(self) -> None: """Validate all configuration components.""" @@ -45,6 +48,7 @@ def validate_all(self) -> None: self.audit.validate_audit_config() self.storage.validate_storage_config() self.nav.validate_nav_config() + self.cache.validate_cache_config() def get_ui_context(self) -> dict: """Get UI configuration for template context.""" diff --git a/fastapi_admin_kit/admin/core.py b/fastapi_admin_kit/admin/core.py index ab15367..f347069 100644 --- a/fastapi_admin_kit/admin/core.py +++ b/fastapi_admin_kit/admin/core.py @@ -23,6 +23,7 @@ AuditConfig, AuthConfig, BehaviorConfig, + CacheConfig, DatabaseConfig, NavConfig, StorageConfig, @@ -56,6 +57,7 @@ def _merge_legacy_kwargs_into_config( behavior: dict[str, Any], storage: dict[str, Any], nav: dict[str, Any], + cache: dict[str, Any] | None = None, ) -> AdminConfig: """Merge explicitly-provided legacy Admin() kwargs into a user-supplied config. @@ -91,6 +93,8 @@ def _merge(sub_config: Any, legacy_values: dict[str, Any]) -> None: _merge(config.behavior, behavior) _merge(config.storage, storage) _merge(config.nav, nav) + if cache is not None: + _merge(config.cache, cache) return config @@ -246,6 +250,9 @@ def __init__( # AI chat file attachments ai_chat_max_file_size_mb: int = 10, ai_chat_allowed_extensions: list[str] | None = None, + # Optional Redis-backed caching (opt-in) + cache_enabled: bool | None = None, + cache_ttl: int | None = None, ): self.registry = AdminRegistry() self._app: FastAPI | None = app @@ -372,6 +379,7 @@ def __init__( ".webp", ], ), + cache=CacheConfig(enabled=cache_enabled, ttl=cache_ttl), ) else: config = _merge_legacy_kwargs_into_config( @@ -431,7 +439,15 @@ def __init__( settings_permission=settings_permission, sidebar_bottom_links=sidebar_bottom_links, ), + cache=dict(enabled=cache_enabled, ttl=cache_ttl), ) + # The merge helper compares against CacheConfig's *constructor* + # defaults, but the default instance already resolved the env + # flag at construction — so apply explicit enabled/ttl overrides. + if cache_enabled is not None: + config.cache.enabled = cache_enabled + if cache_ttl is not None: + config.cache.ttl = cache_ttl if database is None: database = AdminDatabase( @@ -465,6 +481,12 @@ def __init__( self.database = database self.router = router self.template = template + self.cache_config = config.cache + + # Redis availability flags (populated by _setup_redis). + self.redis_enabled = False + self.redis_configured = False + self._redis_wired = False # Store notification paths on config for template access default_notifications_path = f"{self.router.admin_path}/notifications" @@ -521,6 +543,11 @@ def __init__( self._jinja_env: Environment | None = None self._router_built: bool = False + # Wire Redis now (before startup) so the SDK can wrap the lifespan + # before it begins. Degrades gracefully when Redis is unavailable. + if app is not None: + self._setup_redis(app) + if app is not None and engine is not None: # Deferred setup — user will call await admin.setup() via lifespan pass @@ -660,6 +687,11 @@ async def setup(self, app: FastAPI | None = None) -> None: app = self._app + # Wire Redis when the app was supplied after construction (e.g. via + # ``await admin.setup(app)`` inside a manual lifespan). + if not getattr(self, "_redis_wired", False): + self._setup_redis(app) + # Add CSRF middleware if not already added in __init__ if not getattr(self, "_csrf_middleware_added", False): from fastapi_admin_kit.auth.csrf import ( @@ -891,6 +923,38 @@ class MyUserAdmin(UserAdmin): # Internal wiring # ------------------------------------------------------------------ + def _setup_redis(self, app: FastAPI) -> None: + """Wire the optional Redis-backed caching/rate-limiting integration. + + Checks ``REDIS_URL`` at startup: when present (and the + ``fastapi-redis-sdk`` is installed) the SDK's lifespan wrapper plus + caching/rate-limiting support are registered on the app. Otherwise the + admin falls back to the existing in-memory rate limiter and no cache + middleware — full backward compatibility. + """ + if self._redis_wired: + return + self._redis_wired = True + + from fastapi_admin_kit.redis import ( + redis_configured, + redis_enabled, + setup_redis, + ) + + self.redis_configured = redis_configured() + self.redis_enabled = redis_enabled() + + if not self.redis_enabled: + return + + setup_redis( + app, + cache_enabled=self.cache_config.enabled, + cache_ttl=self.cache_config.ttl, + rate_limiting=True, + ) + def _validate_auth_model(self) -> None: """Validate that auth_model satisfies AdminUserProtocol.""" self.config.auth.validate_auth_model() @@ -1210,7 +1274,7 @@ def _build_router(self, app: FastAPI) -> None: # API-only models (export_endpoint="api") get no admin HTML router. if getattr(registered.admin, "export_endpoint", None) == "api": continue - model_router = build_model_router(registered) + model_router = build_model_router(registered, cache_config=self.cache_config) if model_router is None: continue app.include_router(model_router, prefix=self.router.admin_path) diff --git a/fastapi_admin_kit/auth/views.py b/fastapi_admin_kit/auth/views.py index e2cae8f..3e7e16f 100644 --- a/fastapi_admin_kit/auth/views.py +++ b/fastapi_admin_kit/auth/views.py @@ -19,17 +19,12 @@ from fastapi_admin_kit.auth.backend import AuthBackend from fastapi_admin_kit.auth.csrf import CSRF_COOKIE_NAME, require_csrf_token from fastapi_admin_kit.auth.dependencies import _get_db_session, get_session -from fastapi_admin_kit.auth.ratelimit import ( - RateLimiter, - _client_ip, - check_rate_limit, -) +from fastapi_admin_kit.auth.ratelimit import _client_ip from fastapi_admin_kit.auth.session import SessionBackend +from fastapi_admin_kit.redis import LoginRateGuard, get_login_guard router = APIRouter() -_login_rate_limiter = RateLimiter(max_attempts=5, window_seconds=900) - def _is_safe_url(url: str | None) -> bool: """Return True if the URL is relative (no scheme or netloc).""" @@ -105,16 +100,17 @@ async def login_post( next: str | None = Form(None), session: AsyncSession = Depends(_get_db_session), _csrf: bool = Depends(require_csrf_token), + _guard: LoginRateGuard = Depends(get_login_guard), ) -> HTMLResponse | RedirectResponse: """POST /admin/login — process login form.""" client_ip = _client_ip(request) - check_rate_limit(_login_rate_limiter, client_ip) + await _guard.check(client_ip) auth_backend: AuthBackend = request.app.state.admin_auth_backend login_field = request.app.state.admin_config.get("login_field", "email") user = await auth_backend.authenticate(username, password, session, login_field=login_field) if user is not None: - _login_rate_limiter.reset(client_ip) + await _guard.reset(client_ip) user.last_login = datetime.now(UTC) await session.flush() @@ -153,13 +149,13 @@ async def login_post( return response - _login_rate_limiter.record_attempt(client_ip) + await _guard.record_failure(client_ip) from fastapi_admin_kit.auth.models import LoginAttempt note = "Invalid credentials" - if _login_rate_limiter.is_rate_limited(client_ip): - remaining = _login_rate_limiter.remaining_seconds(client_ip) + if await _guard.is_rate_limited(client_ip): + remaining = await _guard.remaining_seconds(client_ip) note = f"Too many failed attempts. Rate limited for {remaining}s" attempt = LoginAttempt( @@ -175,9 +171,9 @@ async def login_post( jinja_env = request.app.state.admin_jinja_env template = jinja_env.get_template("pages/login.html") csrf_token = getattr(request.state, "csrf_token", "") - remaining = _login_rate_limiter.remaining_seconds(client_ip) + remaining = await _guard.remaining_seconds(client_ip) error_msg = "Invalid credentials. Please try again." - if _login_rate_limiter.is_rate_limited(client_ip): + if await _guard.is_rate_limited(client_ip): error_msg = f"Too many failed attempts. Try again in {remaining} seconds." return HTMLResponse( diff --git a/fastapi_admin_kit/config/__init__.py b/fastapi_admin_kit/config/__init__.py index 3444afb..cc8602c 100644 --- a/fastapi_admin_kit/config/__init__.py +++ b/fastapi_admin_kit/config/__init__.py @@ -4,6 +4,7 @@ from fastapi_admin_kit.config.audit import AuditConfig from fastapi_admin_kit.config.auth import AuthConfig from fastapi_admin_kit.config.behavior import BehaviorConfig +from fastapi_admin_kit.config.cache import CacheConfig from fastapi_admin_kit.config.database import DatabaseConfig, DatabaseType from fastapi_admin_kit.config.nav import NavConfig from fastapi_admin_kit.config.storage import StorageConfig @@ -14,6 +15,7 @@ "AIChatConfig", "AuthConfig", "AuditConfig", + "CacheConfig", "DatabaseConfig", "DatabaseType", "UIConfig", diff --git a/fastapi_admin_kit/config/cache.py b/fastapi_admin_kit/config/cache.py new file mode 100644 index 0000000..ec8246b --- /dev/null +++ b/fastapi_admin_kit/config/cache.py @@ -0,0 +1,137 @@ +"""Optional Redis-backed response cache and rate-limit configuration. + +The Redis integration is opt-in. It is only active when Redis is configured +(``REDIS_URL`` is set) *and* the ``fastapi-redis-sdk`` package is installed. +When those conditions are not met the admin behaves exactly as before — no +Redis connection, no cache middleware, no rate limiting, no extra headers. + +Rate limiting is configured here alongside caching so a single +:class:`CacheConfig` controls the whole Redis surface: + +- ``rate_limit`` / ``rate_window`` — default per-model list-endpoint limit. + Every model applies it unless its ``ModelAdmin`` overrides it. +- ``login_rate_limit`` / ``login_rate_window`` — login endpoint limit. +""" + +from __future__ import annotations + +import os + +from fastapi_admin_kit.exceptions import ConfigError + +_DEFAULT_CACHE_TTL = 300 +_DEFAULT_CACHE_PREFIX = "fak" +_DEFAULT_RATE_LIMIT = 5 +_DEFAULT_RATE_WINDOW = 900 + + +def _env_bool(name: str, default: bool = False) -> bool: + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in ("true", "1", "yes", "on") + + +def _env_int(name: str, default: int) -> int: + value = os.environ.get(name) + if value is None: + return default + try: + return int(value) + except (TypeError, ValueError): + raise ConfigError(f"{name} must be an integer.") from None + + +class CacheConfig: + """Configuration for optional Redis-backed caching and rate limiting. + + Global defaults are read from environment variables:: + + FASTAPI_ADMIN_KIT_CACHE_ENABLED=false # opt-in switch + FASTAPI_ADMIN_KIT_CACHE_TTL=300 # seconds + FASTAPI_ADMIN_KIT_RATE_LIMIT_LIMIT=5 # default per-model list limit + FASTAPI_ADMIN_KIT_RATE_LIMIT_WINDOW=900 # window in seconds + FASTAPI_ADMIN_KIT_LOGIN_RATE_LIMIT=5 # login limit (defaults to RATE_LIMIT_LIMIT) + FASTAPI_ADMIN_KIT_LOGIN_RATE_WINDOW=900 # login window (defaults to RATE_LIMIT_WINDOW) + + Explicit constructor arguments take precedence over the environment + defaults, so a per-Admin ``Admin(cache_enabled=True)`` can opt in + even when the global flag is off. + """ + + def __init__( + self, + enabled: bool | None = None, + ttl: int | None = None, + prefix: str = _DEFAULT_CACHE_PREFIX, + rate_limit: int | None = None, + rate_window: int | None = None, + login_rate_limit: int | None = None, + login_rate_window: int | None = None, + ): + # Explicit value wins; otherwise fall back to the global env default. + if enabled is None: + enabled = _env_bool("FASTAPI_ADMIN_KIT_CACHE_ENABLED", False) + if ttl is None: + env_ttl = os.environ.get("FASTAPI_ADMIN_KIT_CACHE_TTL") + if env_ttl is not None: + try: + ttl = int(env_ttl) + except (TypeError, ValueError): + raise ConfigError( + "FASTAPI_ADMIN_KIT_CACHE_TTL must be an integer number of seconds." + ) from None + if ttl is None: + ttl = _DEFAULT_CACHE_TTL + + self.enabled = bool(enabled) + self.ttl = ttl + self.prefix = prefix + + # Per-model list-endpoint rate limit defaults. + if rate_limit is None: + rate_limit = _env_int("FASTAPI_ADMIN_KIT_RATE_LIMIT_LIMIT", _DEFAULT_RATE_LIMIT) + if rate_window is None: + rate_window = _env_int("FASTAPI_ADMIN_KIT_RATE_LIMIT_WINDOW", _DEFAULT_RATE_WINDOW) + self.rate_limit = rate_limit + self.rate_window = rate_window + + # Login rate limit — falls back to the per-model default when unset. + if login_rate_limit is None: + login_rate_limit = _env_int("FASTAPI_ADMIN_KIT_LOGIN_RATE_LIMIT", self.rate_limit) + if login_rate_window is None: + login_rate_window = _env_int("FASTAPI_ADMIN_KIT_LOGIN_RATE_WINDOW", self.rate_window) + self.login_rate_limit = login_rate_limit + self.login_rate_window = login_rate_window + + def validate_cache_config(self) -> None: + """Validate the cache configuration.""" + if self.ttl < 0: + raise ConfigError("cache_ttl must be >= 0 (0 disables expiration).") + for name, value in ( + ("rate_limit", self.rate_limit), + ("rate_window", self.rate_window), + ("login_rate_limit", self.login_rate_limit), + ("login_rate_window", self.login_rate_window), + ): + if value < 1: + raise ConfigError(f"{name} must be >= 1.") + + def to_dict(self) -> dict: + """Return a plain dict for app.state / template context.""" + return { + "enabled": self.enabled, + "ttl": self.ttl, + "prefix": self.prefix, + "rate_limit": self.rate_limit, + "rate_window": self.rate_window, + "login_rate_limit": self.login_rate_limit, + "login_rate_window": self.login_rate_window, + } + + def __repr__(self) -> str: # pragma: no cover - debugging aid + return ( + f"CacheConfig(enabled={self.enabled}, ttl={self.ttl}, prefix={self.prefix!r}, " + f"rate_limit={self.rate_limit}, rate_window={self.rate_window}, " + f"login_rate_limit={self.login_rate_limit}, login_rate_window={self.login_rate_window})" + ) diff --git a/fastapi_admin_kit/modeladmin.py b/fastapi_admin_kit/modeladmin.py index d1ec483..308748a 100644 --- a/fastapi_admin_kit/modeladmin.py +++ b/fastapi_admin_kit/modeladmin.py @@ -76,6 +76,12 @@ def get_ordering(request_params: dict, admin_ordering: list[str] | None) -> list # Inline admin config inlines: list[Any] = [] # list of InlineModelAdmin subclasses + # Optional Redis rate limiting for this model's list endpoint. + # ``None`` falls back to the global default + # (``CacheConfig.rate_limit`` / ``rate_window``). + rate_limit: int | None = None + rate_window: int | None = None + # Conditional fields conditional_fields: dict[str, dict[str, Any]] = {} diff --git a/fastapi_admin_kit/redis.py b/fastapi_admin_kit/redis.py new file mode 100644 index 0000000..cea1e31 --- /dev/null +++ b/fastapi_admin_kit/redis.py @@ -0,0 +1,285 @@ +"""Optional Redis integration helpers built on ``fastapi-redis-sdk``. + +Everything in this module is safe to import even when ``fastapi-redis-sdk`` +is not installed or Redis is not configured. The public functions degrade +gracefully: + +- :func:`redis_enabled` reports whether Redis is actually usable. +- :func:`setup_redis` wires the SDK's lifespan/caching/rate-limiting into a + FastAPI app (a no-op when Redis is unavailable). +- :func:`get_login_guard` returns an async login rate-limit guard that uses + the distributed Redis backend when available and the existing in-memory + limiter otherwise. + +Backward compatibility is preserved: without ``REDIS_URL`` the admin falls +back to the in-memory :class:`~fastapi_admin_kit.auth.ratelimit.RateLimiter` +exactly as before. +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING, Any, Protocol + +from fastapi import HTTPException, Request + +from fastapi_admin_kit.auth.ratelimit import RateLimiter, check_rate_limit + +if TYPE_CHECKING: + from fastapi_admin_kit.config.cache import CacheConfig + +logger = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Redis detection +# --------------------------------------------------------------------------- + + +def redis_url() -> str | None: + """Return the configured ``REDIS_URL`` or ``None``.""" + url = os.environ.get("REDIS_URL") + return url or None + + +def redis_configured() -> bool: + """Return True when ``REDIS_URL`` is set (regardless of SDK availability).""" + return redis_url() is not None + + +def redis_sdk_available() -> bool: + """Return True when the ``fastapi-redis-sdk`` package is importable.""" + try: + import redis_fastapi # noqa: F401 + + return True + except ImportError: + return False + + +def redis_enabled() -> bool: + """Return True when Redis is configured *and* the SDK is installed. + + This is the single source of truth for "should we use Redis?" — every + feature (rate limiting, caching) consults it before reaching for the SDK. + """ + return redis_configured() and redis_sdk_available() + + +# --------------------------------------------------------------------------- +# App wiring +# --------------------------------------------------------------------------- + + +def setup_redis( + app: Any, + *, + cache_enabled: bool = False, + cache_ttl: int = 300, + rate_limiting: bool = True, +) -> bool: + """Wire the Redis connection pool plus caching/rate-limiting into *app*. + + Wraps the app's existing lifespan (via the SDK builder) so the shared + async connection pool lives for the whole application lifetime. Idempotent. + + Returns ``True`` when Redis was wired, ``False`` when it was skipped + (no ``REDIS_URL``, or the SDK is not installed). + """ + if not redis_configured(): + logger.info("REDIS_URL not set — Redis-backed caching/rate limiting disabled") + return False + if not redis_sdk_available(): + logger.warning( + "REDIS_URL is set but 'fastapi-redis-sdk' is not installed. " + "Install it with `pip install fastapi-admin-kit[redis]` to enable " + "Redis-backed caching and rate limiting." + ) + return False + + from redis_fastapi import FastAPIRedis + + builder = FastAPIRedis(app).lifespan() + if cache_enabled: + builder = builder.caching() + if rate_limiting: + builder = builder.rate_limiting() + setattr(app.router.lifespan_context, "_redis_lifespan", True) + logger.info("Redis-backed caching/rate limiting enabled (REDIS_URL set).") + return True + + +def cache_dependency( + cache_config: CacheConfig | None, + eviction_group: str, +) -> Any | None: + """Return a ``Depends(cache(...))`` when caching is active, else ``None``. + + Caching requires three things: an enabled :class:`CacheConfig`, a + configured ``REDIS_URL`` and the SDK being installed. When any of those + is missing the returned dependency is ``None`` so callers simply skip + applying it. + """ + if cache_config is None or not cache_config.enabled: + return None + if not redis_enabled(): + return None + + from fastapi import Depends + from redis_fastapi import cache + + return Depends( + cache( + ttl=cache_config.ttl, + eviction_group=eviction_group, + cache_prefix=cache_config.prefix, + private=True, + ) + ) + + +def rate_limit_dependency( + cache_config: CacheConfig | None, + limit: int | None = None, + window: int | None = None, + *, + scope: str = "", +) -> Any | None: + """Return a ``Depends(rate_limit(...))`` when Redis rate limiting is active. + + Rate limiting needs only a configured ``REDIS_URL`` and the SDK installed + (unlike caching it does *not* require ``cache_config.enabled``). The + ``limit``/``window`` fall back to the global ``CacheConfig`` defaults when + ``None``. Returns ``None`` when Redis is unavailable so callers simply + skip applying it. + """ + if not redis_enabled(): + return None + resolved_limit = limit if limit is not None else getattr(cache_config, "rate_limit", None) + resolved_window = window if window is not None else getattr(cache_config, "rate_window", None) + if ( + resolved_limit is None + or resolved_window is None + or resolved_limit < 1 + or resolved_window < 1 + ): + return None + + from fastapi import Depends + from redis_fastapi import rate_limit + + return Depends(rate_limit(limit=resolved_limit, window=resolved_window, scope=scope)) + + +# --------------------------------------------------------------------------- +# Login rate limiting (Redis-backed with in-memory fallback) +# --------------------------------------------------------------------------- + +LOGIN_RATE_LIMIT_DEFAULT = 5 +LOGIN_RATE_WINDOW_DEFAULT = 900 + + +class LoginRateGuard(Protocol): + """Async interface shared by the in-memory and Redis login guards.""" + + async def check(self, key: str) -> None: ... + async def is_rate_limited(self, key: str) -> bool: ... + async def record_failure(self, key: str) -> None: ... + async def reset(self, key: str) -> None: ... + async def remaining_seconds(self, key: str) -> int: ... + + +class InMemoryLoginRateGuard: + """Async adapter over the existing in-memory :class:`RateLimiter`.""" + + def __init__(self, limiter: RateLimiter) -> None: + self._limiter = limiter + + async def check(self, key: str) -> None: + check_rate_limit(self._limiter, key) + + async def is_rate_limited(self, key: str) -> bool: + return self._limiter.is_rate_limited(key) + + async def record_failure(self, key: str) -> None: + self._limiter.record_attempt(key) + + async def reset(self, key: str) -> None: + self._limiter.reset(key) + + async def remaining_seconds(self, key: str) -> int: + return self._limiter.remaining_seconds(key) + + +class RedisLoginRateGuard: + """Async guard backed by the distributed Redis ``RateLimitBackend``. + + Counters live in Redis so the limit holds across every worker/pod. The + backend fails open when Redis is unreachable — a Redis outage degrades to + "allow and log" rather than taking the admin login down. + """ + + def __init__( + self, + backend: Any, + *, + limit: int = LOGIN_RATE_LIMIT_DEFAULT, + window: int = LOGIN_RATE_WINDOW_DEFAULT, + ) -> None: + self._backend = backend + self.limit = limit + self.window = window + + async def check(self, key: str) -> None: + state = await self._backend.peek(key, limit=self.limit, window=self.window) + if state.remaining == 0: + raise HTTPException( + status_code=429, + detail="Too many attempts. Please try again later.", + headers={"Retry-After": str(state.retry_after)}, + ) + + async def is_rate_limited(self, key: str) -> bool: + state = await self._backend.peek(key, limit=self.limit, window=self.window) + return state.remaining == 0 + + async def record_failure(self, key: str) -> None: + await self._backend.hit(key, limit=self.limit, window=self.window) + + async def reset(self, key: str) -> None: + await self._backend.reset(key) + + async def remaining_seconds(self, key: str) -> int: + state = await self._backend.peek(key, limit=self.limit, window=self.window) + return state.retry_after + + +async def get_login_guard(request: Request) -> LoginRateGuard: + """FastAPI dependency resolving the login rate-limit guard for a request. + + Uses the Redis-backed distributed guard when Redis is enabled, otherwise + falls back to the existing in-memory limiter — preserving the current + auth/rate-limiting behaviour for deployments without Redis. + + Limits come from the admin's :class:`CacheConfig` + (``login_rate_limit`` / ``login_rate_window``), which in turn read the + ``FASTAPI_ADMIN_KIT_LOGIN_RATE_LIMIT`` / ``_WINDOW`` env vars. + """ + admin = getattr(request.app.state, "admin", None) + redis_active = bool(getattr(admin, "redis_enabled", False)) if admin else redis_enabled() + cache_config = getattr(admin, "cache_config", None) + login_limit = getattr(cache_config, "login_rate_limit", LOGIN_RATE_LIMIT_DEFAULT) + login_window = getattr(cache_config, "login_rate_window", LOGIN_RATE_WINDOW_DEFAULT) + if redis_active: + from redis_fastapi import get_rate_limit_backend + + backend = await get_rate_limit_backend(request) + return RedisLoginRateGuard(backend, limit=login_limit, window=login_window) + + limiter = getattr(admin, "_login_rate_limiter", None) + if limiter is None: + limiter = RateLimiter(max_attempts=login_limit, window_seconds=login_window) + if admin is not None: + setattr(admin, "_login_rate_limiter", limiter) + return InMemoryLoginRateGuard(limiter) diff --git a/fastapi_admin_kit/router.py b/fastapi_admin_kit/router.py index b811e5a..385ba23 100644 --- a/fastapi_admin_kit/router.py +++ b/fastapi_admin_kit/router.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any + from fastapi import APIRouter, Depends, HTTPException, Request from fastapi_admin_kit.auth.csrf import require_csrf_token @@ -55,19 +57,33 @@ async def wrapper(*args, **kwargs): return wrapper -def build_model_router(registered: RegisteredModel, *, force: bool = False) -> APIRouter | None: +def build_model_router( + registered: RegisteredModel, + *, + force: bool = False, + cache_config: Any | None = None, +) -> APIRouter | None: """Build the HTML admin router for a model. Returns ``None`` when the model is API-only (``export_endpoint == "api"``) so callers know no admin router should be mounted. Pass ``force=True`` to build the router regardless (used by the standalone ``ModelAdmin.export_admin_route`` helper). + + When *cache_config* is enabled (and Redis is available) the GET list and + GET detail endpoints get a Redis-backed ``cache()`` dependency keyed on an + eviction group per model table — every cached response carries the + ``X-Redis-Cache: HIT/MISS`` header automatically. """ # Models with export_endpoint="api" only expose their JSON API router — # the admin HTML router is skipped entirely. if not force and getattr(registered.admin, "export_endpoint", None) == "api": return None + from fastapi_admin_kit.redis import cache_dependency, rate_limit_dependency + + cache_dep = cache_dependency(cache_config, eviction_group=registered.table_name) + router = APIRouter(prefix=f"/{registered.table_name}", tags=[registered.verbose_name]) # DIP: view classes resolved from ModelAdmin config with defaults @@ -79,11 +95,22 @@ def build_model_router(registered: RegisteredModel, *, force: bool = False) -> A bulk_v = _resolve_view_class(admin, "bulk_view_class", BulkView)(registered) search_v = _resolve_view_class(admin, "search_view_class", SearchView)(registered) + list_deps = [Depends(require_permission(registered.table_name, "view"))] + if cache_dep is not None: + list_deps.append(cache_dep) + rate_dep = rate_limit_dependency( + cache_config, + limit=getattr(admin, "rate_limit", None), + window=getattr(admin, "rate_window", None), + ) + if rate_dep is not None: + list_deps.append(rate_dep) + router.add_api_route( "/", list_v.html_response, methods=["GET"], - dependencies=[Depends(require_permission(registered.table_name, "view"))], + dependencies=list_deps, include_in_schema=False, ) router.add_api_route( @@ -433,11 +460,15 @@ async def validate_field_endpoint( include_in_schema=opts.include_in_schema, ) + detail_deps = [Depends(require_permission(registered.table_name, "edit"))] + if cache_dep is not None: + detail_deps.append(cache_dep) + router.add_api_route( "/{id}", edit_v.html_response, methods=["GET"], - dependencies=[Depends(require_permission(registered.table_name, "edit"))], + dependencies=detail_deps, include_in_schema=False, ) router.add_api_route( diff --git a/mkdocs.yml b/mkdocs.yml index e63ca47..b669416 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -70,6 +70,7 @@ nav: - Navigation & Sidebar: guide/navigation.md - Audit Logging: guide/audit-logging.md - Storage & File Uploads: guide/storage.md + - Redis & Caching: guide/redis-caching.md - Notifications: guide/notifications.md - JSON API: guide/json-api.md - CLI Tools: guide/cli.md diff --git a/pyproject.toml b/pyproject.toml index 7335616..7fa191a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -49,6 +49,9 @@ full = [ "uvicorn>=0.30.0", "pyjwt>=2.13.0", ] +redis = [ + "fastapi-redis-sdk>=0.8.0", +] dev = [ "pytest>=8.0.0", "pytest-asyncio>=0.23.0", @@ -58,6 +61,7 @@ dev = [ "hatch>=1.13.0", "pre-commit>=3.5.0", "pyjwt>=2.13.0", + "fakeredis>=2.26.0", ] sqlmodel = ["sqlmodel>=0.0.39"] postgres = ["asyncpg>=0.29.0"] diff --git a/tests/test_redis_cache.py b/tests/test_redis_cache.py new file mode 100644 index 0000000..e4ba5e8 --- /dev/null +++ b/tests/test_redis_cache.py @@ -0,0 +1,249 @@ +"""Tests for optional Redis-backed caching and rate-limiting fallback.""" + +import pytest +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from fastapi_admin_kit.config import CacheConfig +from fastapi_admin_kit.exceptions import ConfigError +from fastapi_admin_kit.redis import ( + cache_dependency, + get_login_guard, + redis_configured, + redis_enabled, + redis_sdk_available, + setup_redis, +) + + +class TestCacheConfig: + def test_defaults(self, monkeypatch): + monkeypatch.delenv("FASTAPI_ADMIN_KIT_CACHE_ENABLED", raising=False) + monkeypatch.delenv("FASTAPI_ADMIN_KIT_CACHE_TTL", raising=False) + cfg = CacheConfig() + assert cfg.enabled is False + assert cfg.ttl == 300 + assert cfg.prefix == "fak" + + def test_env_enabled_and_ttl(self, monkeypatch): + monkeypatch.setenv("FASTAPI_ADMIN_KIT_CACHE_ENABLED", "true") + monkeypatch.setenv("FASTAPI_ADMIN_KIT_CACHE_TTL", "60") + cfg = CacheConfig() + assert cfg.enabled is True + assert cfg.ttl == 60 + + def test_explicit_args_override_env(self, monkeypatch): + monkeypatch.setenv("FASTAPI_ADMIN_KIT_CACHE_ENABLED", "true") + monkeypatch.setenv("FASTAPI_ADMIN_KIT_CACHE_TTL", "60") + cfg = CacheConfig(enabled=False, ttl=120) + assert cfg.enabled is False + assert cfg.ttl == 120 + + def test_invalid_ttl_env_raises(self, monkeypatch): + monkeypatch.setenv("FASTAPI_ADMIN_KIT_CACHE_TTL", "not-a-number") + with pytest.raises(ConfigError): + CacheConfig() + + def test_negative_ttl_invalid(self): + cfg = CacheConfig(enabled=True, ttl=-1) + with pytest.raises(ConfigError): + cfg.validate_cache_config() + + +class TestRedisDetection: + def test_not_configured(self, monkeypatch): + monkeypatch.delenv("REDIS_URL", raising=False) + assert redis_configured() is False + assert redis_enabled() is False + + def test_configured_without_sdk(self, monkeypatch): + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + import fastapi_admin_kit.redis as redis_mod + + monkeypatch.setattr(redis_mod, "redis_sdk_available", lambda: False) + assert redis_configured() is True + assert redis_enabled() is False + + @pytest.mark.skipif(not redis_sdk_available(), reason="fastapi-redis-sdk not installed") + def test_configured_with_sdk(self, monkeypatch): + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + assert redis_configured() is True + assert redis_enabled() is True + + +class TestSetupRedis: + def test_noop_without_url(self, monkeypatch): + monkeypatch.delenv("REDIS_URL", raising=False) + app = FastAPI() + assert setup_redis(app, cache_enabled=True) is False + + def test_returns_false_without_sdk(self, monkeypatch): + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + import fastapi_admin_kit.redis as redis_mod + + monkeypatch.setattr(redis_mod, "redis_sdk_available", lambda: False) + app = FastAPI() + assert setup_redis(app, cache_enabled=True) is False + + @pytest.mark.skipif(not redis_sdk_available(), reason="fastapi-redis-sdk not installed") + def test_wires_lifespan(self, monkeypatch): + pytest.importorskip("fakeredis.aioredis") + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + app = FastAPI() + assert setup_redis(app, cache_enabled=True, rate_limiting=True) is True + assert getattr(app.router.lifespan_context, "_redis_lifespan", False) is True + + +class TestCacheDependency: + def test_none_when_redis_unavailable(self, monkeypatch): + monkeypatch.delenv("REDIS_URL", raising=False) + cfg = CacheConfig(enabled=True, ttl=60) + assert cache_dependency(cfg, "products") is None + + def test_none_when_disabled(self, monkeypatch): + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + cfg = CacheConfig(enabled=False, ttl=60) + assert cache_dependency(cfg, "products") is None + + @pytest.mark.skipif(not redis_sdk_available(), reason="fastapi-redis-sdk not installed") + def test_returns_depends_when_active(self, monkeypatch): + from fastapi.params import Depends as DependsParam + + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + cfg = CacheConfig(enabled=True, ttl=60) + dep = cache_dependency(cfg, "products") + assert dep is not None + assert isinstance(dep, DependsParam) + + +class TestLoginGuard: + async def test_in_memory_fallback(self, monkeypatch): + monkeypatch.delenv("REDIS_URL", raising=False) + app = FastAPI() + app.state.admin = None + + @app.get("/guard-type") + async def guard_type(guard=Depends(get_login_guard)): + return {"type": type(guard).__name__} + + with TestClient(app) as client: + assert client.get("/guard-type").json()["type"] == "InMemoryLoginRateGuard" + + @pytest.mark.skipif(not redis_sdk_available(), reason="fastapi-redis-sdk not installed") + def test_redis_backed(self, monkeypatch): + pytest.importorskip("fakeredis.aioredis") + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + import fakeredis.aioredis + from redis_fastapi import FastAPIRedis, get_async_redis + + app = FastAPI() + FastAPIRedis(app).lifespan() + fake = fakeredis.aioredis.FakeRedis() + app.dependency_overrides[get_async_redis] = lambda: fake + + class FakeAdmin: + redis_enabled = True + + app.state.admin = FakeAdmin() + + @app.get("/guard-type") + async def guard_type(guard=Depends(get_login_guard)): + return {"type": type(guard).__name__} + + with TestClient(app) as client: + assert client.get("/guard-type").json()["type"] == "RedisLoginRateGuard" + + @pytest.mark.skipif(not redis_sdk_available(), reason="fastapi-redis-sdk not installed") + async def test_redis_guard_enforces_and_resets(self): + from types import SimpleNamespace + from unittest.mock import AsyncMock + + from fastapi import HTTPException + + from fastapi_admin_kit.redis import RedisLoginRateGuard + + backend = AsyncMock() + backend.peek.side_effect = [ + SimpleNamespace(remaining=2, retry_after=0), # check -> ok + SimpleNamespace(remaining=1, retry_after=0), # check -> ok + SimpleNamespace(remaining=0, retry_after=55), # is_rate_limited -> True + SimpleNamespace(remaining=0, retry_after=55), # check -> 429 + SimpleNamespace(remaining=55, retry_after=55), # remaining_seconds + SimpleNamespace(remaining=2, retry_after=0), # is_rate_limited -> False + ] + + guard = RedisLoginRateGuard(backend, limit=2, window=60) + key = "login:1.2.3.4" + await guard.check(key) + await guard.record_failure(key) + await guard.check(key) + assert await guard.is_rate_limited(key) is True + with pytest.raises(HTTPException) as excinfo: + await guard.check(key) + assert excinfo.value.status_code == 429 + assert await guard.remaining_seconds(key) > 0 + await guard.reset(key) + assert await guard.is_rate_limited(key) is False + assert backend.peek.call_count == 6 + await guard.record_failure(key) + backend.hit.assert_awaited() + await guard.reset(key) + backend.reset.assert_awaited() + + async def test_in_memory_guard_matches_limiter(self): + from fastapi_admin_kit.auth.ratelimit import RateLimiter + from fastapi_admin_kit.redis import InMemoryLoginRateGuard + + limiter = RateLimiter(max_attempts=2, window_seconds=60) + guard = InMemoryLoginRateGuard(limiter) + key = "login:1.2.3.4" + await guard.record_failure(key) + await guard.record_failure(key) + assert await guard.is_rate_limited(key) is True + await guard.reset(key) + assert await guard.is_rate_limited(key) is False + + +class TestBuildModelRouterCaching: + @pytest.mark.skipif(not redis_sdk_available(), reason="fastapi-redis-sdk not installed") + def test_cache_dep_applied_to_list_and_detail(self, monkeypatch): + monkeypatch.setenv("REDIS_URL", "redis://localhost:6379/0") + from fastapi_admin_kit.registry import build_registered_model + from fastapi_admin_kit.router import build_model_router + from fastapi_admin_kit.views import ModelAdmin + from tests.test_registry import Product + + registered = build_registered_model(Product, ModelAdmin()) + router = build_model_router(registered, cache_config=CacheConfig(enabled=True, ttl=60)) + assert router is not None + + cached_paths = set() + for route in router.routes: + path = getattr(route, "path", None) + methods = getattr(route, "methods", None) or set() + if path is None or "GET" not in methods: + continue + deps = getattr(route, "dependant", None) + if deps is None: + continue + modules = {getattr(d.call, "__module__", "") for d in deps.dependencies} + if "redis_fastapi.cache" in modules: + cached_paths.add(path) + assert cached_paths == {"/products/", "/products/{id}"} + + def test_no_cache_dep_when_redis_unavailable(self, monkeypatch): + monkeypatch.delenv("REDIS_URL", raising=False) + from fastapi_admin_kit.registry import build_registered_model + from fastapi_admin_kit.router import build_model_router + from fastapi_admin_kit.views import ModelAdmin + from tests.test_registry import Product + + registered = build_registered_model(Product, ModelAdmin()) + router = build_model_router(registered, cache_config=CacheConfig(enabled=True, ttl=60)) + assert router is not None + for route in router.routes: + deps = getattr(route, "dependant", None) + if deps is None: + continue + modules = {getattr(d.call, "__module__", "") for d in deps.dependencies} + assert "redis_fastapi.cache" not in modules From 13b5894b4c69f268116607dcfd1f45ff9a4a17e0 Mon Sep 17 00:00:00 2001 From: borhanst Date: Fri, 21 Aug 2026 14:30:58 +0600 Subject: [PATCH 02/12] Implement TOTP 2FA verification on login, enforce security measures against privilege escalation, and address XSS vulnerabilities in admin interface. Add regression tests for sensitive fields, stale JWTs, and mass assignment protections. --- fastapi_admin_kit/admin/builtin_models.py | 31 +++ fastapi_admin_kit/api/deps.py | 100 ++++---- fastapi_admin_kit/api/schema_generator.py | 4 +- fastapi_admin_kit/auth/session.py | 18 ++ fastapi_admin_kit/auth/totp.py | 47 ++++ fastapi_admin_kit/auth/views.py | 22 ++ fastapi_admin_kit/export_import/base.py | 10 +- fastapi_admin_kit/inspection/types.py | 13 + fastapi_admin_kit/router.py | 56 +++-- .../templates/admin/base_list.html | 12 +- .../templates/macros/form_fields.html | 15 +- fastapi_admin_kit/templates/macros/table.html | 2 +- .../templates/pages/roles/form.html | 2 +- .../templates/pages/users/form.html | 4 +- .../templates/partials/_inline_edit_row.html | 2 +- .../templates/partials/inline_stacked.html | 4 +- .../templates/partials/inline_tabular.html | 4 +- .../templates/partials/list_table.html | 2 +- .../partials/perm_select_widget.html | 2 +- .../templates/partials/permission_widget.html | 2 +- fastapi_admin_kit/views/class_views.py | 4 +- fastapi_admin_kit/views/model_saver.py | 7 + fastapi_admin_kit/views/renderers.py | 4 +- fastapi_admin_kit/views/totp.py | 101 +++++++- tests/test_search.py | 4 +- tests/test_security_mass_assignment.py | 228 +++++++++++++++++ tests/test_security_rbac.py | 202 +++++++++++++++ tests/test_security_sensitive_fields.py | 138 +++++++++++ tests/test_security_stale_jwt.py | 233 ++++++++++++++++++ tests/test_security_totp_login.py | 198 +++++++++++++++ tests/test_security_xss.py | 152 ++++++++++++ 31 files changed, 1506 insertions(+), 117 deletions(-) create mode 100644 tests/test_security_mass_assignment.py create mode 100644 tests/test_security_rbac.py create mode 100644 tests/test_security_sensitive_fields.py create mode 100644 tests/test_security_stale_jwt.py create mode 100644 tests/test_security_totp_login.py create mode 100644 tests/test_security_xss.py diff --git a/fastapi_admin_kit/admin/builtin_models.py b/fastapi_admin_kit/admin/builtin_models.py index cf05319..15758be 100644 --- a/fastapi_admin_kit/admin/builtin_models.py +++ b/fastapi_admin_kit/admin/builtin_models.py @@ -4,6 +4,7 @@ from typing import Any +from fastapi_admin_kit.inspection.types import SENSITIVE_FIELDS from fastapi_admin_kit.modeladmin import ModelAdmin from fastapi_admin_kit.types import ExtraField from fastapi_admin_kit.widgets.inputs import AutocompleteWidget, PasswordWidget @@ -124,9 +125,33 @@ class UserAdmin(ModelAdmin): ), } + def _actor_is_superuser(self, request) -> bool: + """Return True if the acting user may modify privileged user fields.""" + if request is None: + return False + snapshot = getattr(request.state, "admin_user_snapshot", None) or {} + return bool(snapshot.get("is_superuser", False)) + + def _strip_privileged_fields(self, data, request): + """Remove privilege-granting fields unless a superuser is acting. + + Prevents privilege escalation via mass assignment: a non-superuser + with edit permission on admin_users must not grant themselves + ``is_superuser``, toggle ``is_active``, change ``roles``, or write + ``hashed_password`` directly. + """ + if self._actor_is_superuser(request): + return data + for field in SENSITIVE_FIELDS: + data.pop(field, None) + return data + def prepare_create_data(self, data, request=None): from fastapi_admin_kit.auth.models import User + # Strip first so a direct hashed_password injection is removed, + # then derive hashed_password from the (validated) password field. + self._strip_privileged_fields(data, request) password = data.pop("password", None) if password: data["hashed_password"] = User.hash_password(password) @@ -162,6 +187,12 @@ def validate_update(self, obj, data, request=None): raise FieldError({"password": errors}) return data + def prepare_update_data(self, data: dict[str, Any], request: Any = None) -> dict[str, Any]: + """Strip extra fields and privileged fields before an update.""" + extra_names = {f.name for f in self.extra_fields} + data = {k: v for k, v in data.items() if k not in extra_names} + return self._strip_privileged_fields(data, request) + def on_update(self, obj, data, request=None): pass diff --git a/fastapi_admin_kit/api/deps.py b/fastapi_admin_kit/api/deps.py index eb2dde3..fc68cdc 100644 --- a/fastapi_admin_kit/api/deps.py +++ b/fastapi_admin_kit/api/deps.py @@ -1,10 +1,9 @@ -"""API dependencies — JWT-based permission checking with DB fallback. +"""API dependencies — JWT authentication with live DB authorization. -Primary source of truth is the permission snapshot embedded in the JWT at -login time (fast, no DB hit). When the snapshot does not grant the action, -we fall back to a live :class:`PermissionChecker` query so permissions that -were granted *after* the token was issued take effect immediately instead of -requiring the user to log in again. +The JWT authenticates *identity* (signature + expiry). Authorization is +always checked against the live database via :class:`PermissionChecker`, so +revoked roles/permissions and demoted or deactivated users take effect +immediately instead of persisting until token expiry. """ from __future__ import annotations @@ -33,48 +32,30 @@ async def get_api_current_user(request: Request) -> dict[str, Any]: return payload -async def _check_live_permission( - request: Request, - user: dict[str, Any], - table_name: str, - action: str, -) -> bool: - """Check *action* on *table_name* against the database. +async def _resolve_live_user(request: Request, user: dict[str, Any]) -> Any | None: + """Resolve the JWT subject to the current DB user. - Resolves the current user from the JWT subject via the configured auth - backend and runs a fresh :class:`PermissionChecker`. Used as a fallback - when the JWT-embedded permission snapshot is stale. + Returns ``None`` when the account was deleted or deactivated, so stale + tokens cannot keep working after the account is removed. """ sub = user.get("sub") if sub is None: - return False + return None try: user_id: int | str = int(sub) except (TypeError, ValueError): - return False + return None from fastapi_admin_kit.auth.identity import resolve_user - from fastapi_admin_kit.auth.permissions import PermissionChecker - from fastapi_admin_kit.db import get_db_session - session = get_db_session(request) - if session is None: - return False - - resolved = await resolve_user(request, user_id) - if resolved is None: - return False - - checker = PermissionChecker( - session=session, - user=resolved, - user_snapshot=getattr(request.state, "admin_user_snapshot", None), - ) - return await checker.has_permission(table_name, action) + try: + return await resolve_user(request, user_id) + except Exception: + return None def require_api_permission(table_name: str, action: str): - """Return a dependency that checks JWT-embedded permissions. + """Return a dependency that authorizes *action* on *table_name*. Usage:: @@ -82,40 +63,57 @@ def require_api_permission(table_name: str, action: str): async def list_view(user=Depends(require_api_permission("products", "view"))): ... - Superusers always pass. When the JWT snapshot does not grant the action, - a live DB check is performed so newly-granted permissions take effect - without requiring the user to re-authenticate. + The JWT authenticates identity; authorization is always evaluated + against the live database so permission revocations, role changes, + superuser demotion and account deactivation apply immediately. """ async def _check(request: Request) -> dict[str, Any]: user = await get_api_current_user(request) - if user.get("is_superuser"): - return user + from fastapi_admin_kit.auth.permissions import PermissionChecker + from fastapi_admin_kit.db import get_db_session - permissions = user.get("permissions", {}) - table_perms = permissions.get(table_name, []) - if action in table_perms: - return user + resolved = await _resolve_live_user(request, user) + if resolved is None: + raise HTTPException( + status_code=401, + detail="Account not found or inactive.", + ) - if await _check_live_permission(request, user, table_name, action): - return user + session = get_db_session(request) + if session is None: + raise HTTPException(status_code=503, detail="Database unavailable.") - raise HTTPException( - status_code=403, - detail=f"You do not have permission to {action} {table_name}.", + checker = PermissionChecker( + session=session, + user=resolved, + user_snapshot=getattr(request.state, "admin_user_snapshot", None), ) + if not await checker.has_permission(table_name, action): + raise HTTPException( + status_code=403, + detail=f"You do not have permission to {action} {table_name}.", + ) + return user return _check def require_api_superuser(): - """Return a dependency that enforces superuser access from JWT.""" + """Return a dependency that enforces superuser access from live DB state.""" async def _check(request: Request) -> dict[str, Any]: user = await get_api_current_user(request) - if not user.get("is_superuser"): + resolved = await _resolve_live_user(request, user) + if resolved is None: + raise HTTPException( + status_code=401, + detail="Account not found or inactive.", + ) + + if not getattr(resolved, "is_superuser", False): raise HTTPException(status_code=403, detail="Superuser access required.") return user diff --git a/fastapi_admin_kit/api/schema_generator.py b/fastapi_admin_kit/api/schema_generator.py index 24f27e8..d8a3172 100644 --- a/fastapi_admin_kit/api/schema_generator.py +++ b/fastapi_admin_kit/api/schema_generator.py @@ -161,7 +161,9 @@ def build_update_schema(registered: Any) -> type[BaseModel]: def build_response_schema(registered: Any) -> type[BaseModel]: """Build a Pydantic model for response output.""" - columns = list(registered.columns) + from fastapi_admin_kit.inspection.types import SENSITIVE_FIELDS + + columns = [c for c in registered.columns if c.name not in SENSITIVE_FIELDS] fields: dict[str, Any] = {} for col in columns: diff --git a/fastapi_admin_kit/auth/session.py b/fastapi_admin_kit/auth/session.py index ad9d409..ff78503 100644 --- a/fastapi_admin_kit/auth/session.py +++ b/fastapi_admin_kit/auth/session.py @@ -47,6 +47,10 @@ def __init__( ) -> None: self._secret_key = secret_key self._serializer = URLSafeTimedSerializer(secret_key, salt="admin-session") + # Separate salt + short TTL: a pending-2FA token can never be + # replayed as a full session cookie (different signing domain). + self._mfa_serializer = URLSafeTimedSerializer(secret_key, salt="admin-2fa") + self._mfa_ttl = 300 self._session_ttl = session_ttl self.cookie_name = cookie_name self.secure = secure @@ -88,6 +92,20 @@ def load(self, token: str | None) -> dict[str, Any] | None: """Alias for decode — used by flash message system.""" return self.decode(token) + def encode_pending_2fa(self, user_id: int | str) -> str: + """Sign a short-lived token proving credentials were verified but 2FA is pending.""" + return self._mfa_serializer.dumps({"user_id": user_id}) + + def decode_pending_2fa(self, token: str | None) -> int | str | None: + """Verify a pending-2FA token and return the user_id, or ``None``.""" + if not token: + return None + try: + payload = self._mfa_serializer.loads(token, max_age=self._mfa_ttl) + except (BadSignature, SignatureExpired, ValueError): + return None + return payload.get("user_id") + def save(self, response: Any, data: dict[str, Any], *, request: Any | None = None) -> None: """Encode *data* and set it as a signed cookie on *response*.""" token = self.encode(data) diff --git a/fastapi_admin_kit/auth/totp.py b/fastapi_admin_kit/auth/totp.py index 84f7627..c4b7705 100644 --- a/fastapi_admin_kit/auth/totp.py +++ b/fastapi_admin_kit/auth/totp.py @@ -8,6 +8,7 @@ import secrets import struct import time +from typing import Any def generate_secret() -> str: @@ -84,3 +85,49 @@ def verify_backup_code(code: str, hashed_codes: list[str]) -> bool: hashed_codes.pop(i) return True return False + + +# --------------------------------------------------------------------------- +# Data access — the only place that queries TOTP records. +# +# Views must call these helpers instead of building queries themselves. +# Queries are built through the ``QueryBackend`` adapter (``select``/``where``) +# and executed through the backend-agnostic ``SessionBackend`` wrapper, so +# storage stays swappable (SQLAlchemy, memory, …). +# --------------------------------------------------------------------------- + + +async def get_totp_record( + session: Any, + user_id: int | str, + query_adapter: Any = None, +) -> Any | None: + """Return the ``UserTOTP`` row for *user_id*, or ``None``. + + *session* may be a raw ORM session or a ``SessionBackend`` — it is + coerced through :func:`fastapi_admin_kit.backends.as_session_backend`. + *query_adapter* is the ``QueryBackend`` from ``app.state.admin_query_adapter``; + when omitted, the default SQLAlchemy adapter is used (CLI / no-app contexts). + """ + from fastapi_admin_kit.auth.models import UserTOTP + from fastapi_admin_kit.backends import as_session_backend + + session = as_session_backend(session) + if query_adapter is None: + from fastapi_admin_kit.backends.sqlalchemy import SqlAlchemyQueryAdapter + + query_adapter = SqlAlchemyQueryAdapter() + + query = query_adapter.select(UserTOTP) + query = query_adapter.where(query, UserTOTP.user_id == user_id) + return await session.scalar_one_or_none(query) + + +async def has_totp_enabled( + session: Any, + user_id: int | str, + query_adapter: Any = None, +) -> bool: + """Return True when *user_id* has an enabled TOTP record.""" + record = await get_totp_record(session, user_id, query_adapter) + return record is not None and bool(record.enabled) diff --git a/fastapi_admin_kit/auth/views.py b/fastapi_admin_kit/auth/views.py index 3e7e16f..b12e5ac 100644 --- a/fastapi_admin_kit/auth/views.py +++ b/fastapi_admin_kit/auth/views.py @@ -114,7 +114,29 @@ async def login_post( user.last_login = datetime.now(UTC) await session.flush() + # ── 2FA enforcement (S02) ──────────────────────────────────── + # If the user has TOTP enabled, do NOT issue a session cookie. + # Issue a short-lived pending token and redirect to /verify-2fa. from fastapi_admin_kit.auth.models import LoginAttempt + from fastapi_admin_kit.auth.totp import has_totp_enabled + + query_adapter = getattr(request.app.state, "admin_query_adapter", None) + if await has_totp_enabled(session, user.id, query_adapter): + attempt = LoginAttempt( + email=username, + ip_address=client_ip, + user_agent=request.headers.get("user-agent", ""), + success=True, + note="Credentials verified — 2FA required", + ) + session.add(attempt) + await session.flush() + + session_backend: SessionBackend = request.app.state.admin_session_backend + temp_token = session_backend.encode_pending_2fa(user.id) + admin_path = request.app.state.admin_config["admin_path"] + redirect_url = f"{admin_path}/verify-2fa?temp_token={temp_token}" + return RedirectResponse(url=redirect_url, status_code=status.HTTP_302_FOUND) attempt = LoginAttempt( email=username, diff --git a/fastapi_admin_kit/export_import/base.py b/fastapi_admin_kit/export_import/base.py index dc19214..bf749f8 100644 --- a/fastapi_admin_kit/export_import/base.py +++ b/fastapi_admin_kit/export_import/base.py @@ -52,11 +52,17 @@ def get_columns(self) -> list[str]: """Get the list of columns to export. Returns configured columns or all model columns. - Excludes primary key columns by default. + Excludes primary key and sensitive columns by default. """ + from fastapi_admin_kit.inspection.types import SENSITIVE_FIELDS + if self.columns: return self.columns - return [c.name for c in self.registered.columns if not c.primary_key] + return [ + c.name + for c in self.registered.columns + if not c.primary_key and c.name not in SENSITIVE_FIELDS + ] def get_headers(self) -> dict[str, str]: """Get column headers for export. diff --git a/fastapi_admin_kit/inspection/types.py b/fastapi_admin_kit/inspection/types.py index ef447dc..0e9b8ff 100644 --- a/fastapi_admin_kit/inspection/types.py +++ b/fastapi_admin_kit/inspection/types.py @@ -5,6 +5,19 @@ from dataclasses import dataclass, field from typing import Any +SENSITIVE_FIELDS: frozenset[str] = frozenset( + { + "hashed_password", + "password_changed_at", + "password", + "secret", + "secret_key", + "token", + "refresh_token", + } +) +"""Column names that must never appear in serialized API output.""" + @dataclass class ColumnMeta: diff --git a/fastapi_admin_kit/router.py b/fastapi_admin_kit/router.py index 385ba23..2b2c27e 100644 --- a/fastapi_admin_kit/router.py +++ b/fastapi_admin_kit/router.py @@ -460,6 +460,34 @@ async def validate_field_endpoint( include_in_schema=opts.include_in_schema, ) + # ── Sortable Endpoint ────────────────────────────────────────── + # Registered before the ``/{id}`` catch-all so POST /sort is not + # swallowed by the edit-view route. + + @router.post("/sort", include_in_schema=False) + async def sort_items( + request: Request, + _csrf: bool = Depends(require_csrf_token), + _: None = Depends(require_permission(registered.table_name, "edit")), + ): + """Handle drag-drop sort updates.""" + body = await request.json() + ordering_field = getattr(registered.admin, "ordering_field", None) + if not ordering_field: + raise HTTPException(status_code=400, detail="Sorting not configured") + + session = get_db_session(request) + items = body.get("items", []) + for idx, item_id in enumerate(items): + obj = await session.get(registered.model, item_id) + if obj: + setattr(obj, ordering_field, idx) + await session.flush() + + from fastapi.responses import HTMLResponse + + return HTMLResponse(content="OK") + detail_deps = [Depends(require_permission(registered.table_name, "edit"))] if cache_dep is not None: detail_deps.append(cache_dep) @@ -727,6 +755,7 @@ async def execute_list_action( request: Request, action_name: str, _csrf: bool = Depends(require_csrf_token), + _: None = Depends(require_permission(registered.table_name, "edit")), ): """Execute a list-level action on selected objects.""" session = get_db_session(request) @@ -762,6 +791,7 @@ async def execute_row_action( action_name: str, id: str, _csrf: bool = Depends(require_csrf_token), + _: None = Depends(require_permission(registered.table_name, "edit")), ): """Execute a row-level action on a single object.""" session = get_db_session(request) @@ -788,31 +818,6 @@ async def execute_row_action( return HTMLResponse(content="OK") - # ── Sortable Endpoint ────────────────────────────────────────── - - @router.post("/sort", include_in_schema=False) - async def sort_items( - request: Request, - _csrf: bool = Depends(require_csrf_token), - ): - """Handle drag-drop sort updates.""" - body = await request.json() - ordering_field = getattr(registered.admin, "ordering_field", None) - if not ordering_field: - raise HTTPException(status_code=400, detail="Sorting not configured") - - session = get_db_session(request) - items = body.get("items", []) - for idx, item_id in enumerate(items): - obj = await session.get(registered.model, item_id) - if obj: - setattr(obj, ordering_field, idx) - await session.flush() - - from fastapi.responses import HTMLResponse - - return HTMLResponse(content="OK") - # ── Autocomplete Endpoint ────────────────────────────────────── @router.get("/autocomplete/", include_in_schema=False) @@ -851,6 +856,7 @@ async def update_field( request: Request, id: str, _csrf: bool = Depends(require_csrf_token), + _: None = Depends(require_permission(registered.table_name, "edit")), ): """Inline field update — used by toggle switches in list view.""" from fastapi_admin_kit.auth.csrf import _get_secret_key, generate_csrf_token diff --git a/fastapi_admin_kit/templates/admin/base_list.html b/fastapi_admin_kit/templates/admin/base_list.html index 10f78a2..3fea367 100644 --- a/fastapi_admin_kit/templates/admin/base_list.html +++ b/fastapi_admin_kit/templates/admin/base_list.html @@ -268,11 +268,11 @@

Import Data

{% else %} {# boolean, enum, relation — render as dropdown #}