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/alembic-setup.md b/docs/guide/alembic-setup.md index 041a2d0..d1abb95 100644 --- a/docs/guide/alembic-setup.md +++ b/docs/guide/alembic-setup.md @@ -46,6 +46,20 @@ target_metadata = [AdminBase.metadata] # target_metadata.append(AppBase.metadata) # Add your models ``` +!!! warning "All admin tables are included by default" + + `AdminBase.metadata` is a single shared `MetaData` registry: every model + imported from `fastapi_admin_kit.migrations.models` (users, roles, + permissions, junction tables, refresh tokens, audit log, TOTP, login + attempts, and the `admin_ai_*` tables) registers itself on it. Since + `.metadata` refers to that whole registry — not just one model — running + `alembic revision --autogenerate` will generate **all** admin tables at + once. It is **your responsibility** to specify which models you want. + + For an initial `init db` migration this is usually exactly what you want. + If you only want a subset of the tables, see + [Tracking Only Specific Tables](#tracking-only-specific-tables). + ## Manual Alembic Setup If you prefer manual setup or have an existing Alembic configuration: @@ -268,6 +282,61 @@ Then autogenerate will include both admin and app tables: alembic revision --autogenerate -m "add product table" ``` +## Tracking Only Specific Tables + +Because `AdminBase.metadata` is a shared registry, autogenerate compares +**every** admin table against your database. If you only want Alembic to track +a subset of tables, use the `include_name` (or `include_object`) hook in +`alembic/env.py`. Excluded tables are neither reflected nor compared, so no +statements are generated for them at all. + +Filter by table name: + +```python +def include_name(name, type_, parent_names): + if type_ == "table": + return name in {"admin_users", "admin_roles"} # only these tables + return True + + +# Pass it to every context.configure() call — both offline and online: +def run_migrations_offline() -> None: + ... + context.configure( + url=url, + target_metadata=target_metadata, + include_name=include_name, + ... + ) + + +def do_run_migrations(connection: Connection) -> None: + context.configure( + connection=connection, + target_metadata=target_metadata, + include_name=include_name, + ) +``` + +Or filter by object, which also lets you inspect the `Table` itself: + +```python +TRACKED = {"admin_users", "admin_roles"} + + +def include_object(obj, name, type_, reflected, compare_to): + if type_ == "table" and obj.metadata is AdminBase.metadata: + return name in TRACKED # admin tables: only the tracked set + return True # everything else (e.g. your app tables) is kept +``` + +The `include_object` variant keeps all of *your* app tables while restricting +admin tables to the tracked set. + +Alternatively, you can simply delete unwanted statements from a generated +migration before applying it — fine for one-offs, but the hooks keep every +future autogenerate consistent. + ## Junction Tables The admin models include two junction tables for many-to-many relationships: diff --git a/docs/guide/custom-auth-model.md b/docs/guide/custom-auth-model.md new file mode 100644 index 0000000..aec6b6c --- /dev/null +++ b/docs/guide/custom-auth-model.md @@ -0,0 +1,176 @@ +# Custom Auth Model + +Use your own user model instead of the built-in `admin_users` table. This is +the recommended pattern when your project already has a `User` model +(SQLAlchemy, SQLModel, or anything that satisfies +`AdminUserProtocol`). + +## Why use a custom auth model + +- You already have a `User` model with your schema, password hashing, and + email-verification flow. Reusing it avoids two parallel user tables. +- Your `User.id` may be a `UUID`, ULID, or `String(36)` — the admin tables + adapt their `user_id` columns to match. +- Roles, permissions, and audit log records reference your real user IDs. + +## Minimal example + +```python +import uuid +from typing import Optional + +from fastapi import FastAPI +from sqlalchemy import Column, ForeignKey, Integer, String +from sqlalchemy.orm import DeclarativeBase, relationship +from sqlalchemy.types import Uuid + +from fastapi_admin_kit import Admin, DatabaseConfig, DatabaseType +from fastapi_admin_kit.auth.mixins import AuthModelMixin + + +class Base(DeclarativeBase): + pass + + +class User(AuthModelMixin, Base): + __tablename__ = "users" + + id = Column(Uuid, primary_key=True, default=uuid.uuid4) + email = Column(String(255), unique=True, nullable=False, index=True) + hashed_password = Column(String(255), nullable=False) + name = Column(String(255), nullable=True) + + # AuthModelMixin provides the rest of the protocol surface + # (is_active, is_superuser, role_ids, verify_password, etc.). + + +app = FastAPI() +admin = Admin( + app=app, + base=Base, + database_config=DatabaseConfig( + db_type=DatabaseType.POSTGRESQL, + url="postgresql+asyncpg://user:pass@localhost/app", + ), + secret_key="change-me-to-32-chars-or-more-please", + auth_model=User, +) +``` + +When `auth_model=` is supplied: + +1. The built-in `admin_users` and `admin_user_roles` tables are **not** + created. Your `User` table is the single source of truth. +2. The built-in log-pattern `user_id` columns (`admin_audit_log.user_id`, + `admin_user_permissions.user_id`, `admin_refresh_tokens.user_id`, + etc.) are retyped to match your `User.id` type — e.g. `Uuid` if your + `User.id` is a `Uuid`. +3. The built-in `UserAdmin` CRUD view is **not** registered. Register + your own `UserAdmin` subclass against your model if you want it in + the sidebar. + +## Lifespan setup + +The recommended pattern is to call `admin.create_tables()` from your +`lifespan` instead of touching `AdminBase.metadata` directly. The +package will skip the built-in user tables and let your project's +metadata own them. + +```python +from contextlib import asynccontextmanager +from fastapi import FastAPI +from sqlmodel import SQLModel # or your ORM of choice + +@asynccontextmanager +async def lifespan(app: FastAPI): + # 1. Create your project's tables first + async with engine.begin() as conn: + await conn.run_sync(SQLModel.metadata.create_all) + + # 2. Create the admin tables (built-in user tables are skipped + # because auth_model=User is configured) + await admin.create_tables() + + # 3. Wire up routes, middleware, templates + await admin.setup(app) + + yield +``` + +> **Do not** call `AdminBase.metadata.create_all` directly. It bypasses +> the custom-auth_model logic and will create the default +> `admin_users` / `admin_user_roles` tables even when you supplied your +> own user model. Always go through `admin.create_tables()` or +> `admin.setup()`. + +## What you keep + +Even with a custom `auth_model`, the admin still manages these tables: + +- `admin_roles` and `admin_permissions` (RBAC) +- `admin_user_permissions` (per-user permission overrides) +- `admin_refresh_tokens` (session storage) +- `admin_user_totp` (2FA secrets) +- `admin_audit_log` (change history with `user_id` and `user_email`) +- `admin_notifications` and friends (when notifications are enabled) +- `admin_ai_*` (when AI is enabled) + +## What you bring + +Your `auth_model` must satisfy `AdminUserProtocol` (validated at +`Admin()` construction time): + +| Attribute | Type | Notes | +|-----------|------|-------| +| `id` | any | The PK type — `int`, `UUID`, etc. | +| `email` | `str` | Used as the login identifier | +| `is_active` | `bool` | Inactive users cannot log in | +| `is_superuser` | `bool` | Bypasses all RBAC checks | +| `hashed_password` | `str` | bcrypt / argon2 hash | +| `role_ids` | `list[int]` | Property that returns role IDs | +| `roles` | relationship | M2M to `Role` model | +| `verify_password(plain)` | method | Returns `bool` | + +`fastapi_admin_kit.auth.mixins.AuthModelMixin` provides all of the +above for SQLAlchemy declarative models — inherit from it to get +`is_active`, `is_superuser`, `role_ids`, and password helpers for free. + +## Troubleshooting + +### `'NoneType' object has no attribute '_run_ddl_visitor'` + +You called `AdminBase.metadata.create_all` directly, or you constructed +`Admin()` without an engine and without a `database_config`. Pass one +of: + +```python +admin = Admin( + engine=engine, # explicit engine + # or + database_config=DatabaseConfig(...), # lazy engine from URL +) +``` + +If you want the admin to skip table creation entirely (e.g. you manage +schema with Alembic), pass `use_alembic=True`. + +### `admin_users` table keeps being created + +Make sure you're using `admin.create_tables()` or `admin.setup()`, **not** +`AdminBase.metadata.create_all`. The package never mutates the shared +`AdminBase.metadata` (its `FacadeDict` is immutable); it filters the +excluded tables only at `create_all` time, and only via the public +`Admin` API. + +### `TypeError: Object of type UUID is not JSON serializable` on login + +The session cookie is signed via `itsdangerous`, whose default JSON +encoder doesn't know about `UUID`. The admin's `SignedCookieSessionBackend` +registers a `default=` handler that converts `UUID` → `str`, `datetime` +→ ISO string, `Decimal` → `str`, and `set`/`frozenset` → `list`. This +covers every payload type a typical `auth_model` exposes (`user.id`, +`iat`, optional `exp`), so a UUID PK works out of the box. + +If you replace the session backend with a custom one, make sure your +encoder handles the same set of types — or coerce `user.id` to `str` +before stuffing it into the payload. diff --git a/docs/guide/existing-alembic-integration.md b/docs/guide/existing-alembic-integration.md index 3ed3361..8e9425d 100644 --- a/docs/guide/existing-alembic-integration.md +++ b/docs/guide/existing-alembic-integration.md @@ -17,6 +17,19 @@ from fastapi_admin_kit.migrations.models import Base as AdminBase target_metadata = [AppBase.metadata, AdminBase.metadata] ``` +!!! warning "All admin tables are included by default" + + `AdminBase.metadata` is a single shared `MetaData` registry: every model + imported from `fastapi_admin_kit.migrations.models` registers itself on + it. Since `.metadata` refers to that whole registry — not just one model — + autogenerate will generate **all** admin tables at once. It is **your + responsibility** to specify which models you want. + + For an initial migration this is usually exactly what you want. If you + only want a subset of the tables, filter them with an `include_name` / + `include_object` hook — see + [Tracking Only Specific Tables](alembic-setup.md#tracking-only-specific-tables). + That's it! Now run: ```bash 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/model-registration.md b/docs/guide/model-registration.md index cb136a6..a6c5a99 100644 --- a/docs/guide/model-registration.md +++ b/docs/guide/model-registration.md @@ -448,9 +448,14 @@ usual (query/path params, bodies, etc.). | `summary` | `str` | No | `""` | OpenAPI summary | | `response_description` | `str` | No | `""` | OpenAPI response description | | `permission` | `str` | No | `None` | RBAC action enforced via `require_permission` | +| `allow_anonymous` | `bool` | No | `False` | Set `True` to expose the endpoint without authentication | ### RBAC +Endpoints are **secure by default**: without an explicit `permission` (or +`allow_anonymous=True`) an endpoint requires an authenticated user with +`view` permission on the model. + Setting `permission` enforces the same RBAC used by the built-in routes (a `require_permission("", "")` dependency). You can also pass arbitrary dependencies directly: @@ -467,6 +472,14 @@ async def stats(self, request): pass ``` +To publish a genuinely public endpoint, opt out explicitly: + +```python +@endpoint(path="/health", allow_anonymous=True) +async def health(self, request): + return {"status": "ok"} +``` + ## Customizing Built-in Admin Models FastAPI Admin Kit ships with default admin classes for built-in models (users, roles, audit logs, etc.). You can customize these by inheriting from the default classes. diff --git a/docs/guide/redis-caching.md b/docs/guide/redis-caching.md new file mode 100644 index 0000000..31a3e12 --- /dev/null +++ b/docs/guide/redis-caching.md @@ -0,0 +1,109 @@ +# 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 auth endpoints use a +distributed `RateLimitBackend` so the attempt counters hold across every +worker or pod. Guarded endpoints: + +| Endpoint | Bucket key | Default limit | +|----------|-----------|---------------| +| `POST /admin/login` (HTML) | client IP | 5 per 900 s | +| `POST /api/auth/token` | client IP + email (failed attempts) | 10 per 900 s | +| `POST /api/auth/refresh` | client IP | 60 per 300 s | +| `POST /api/auth/logout` | client IP | 30 per 300 s | + +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` an in-memory limiter is used. + +!!! warning "Multi-worker deployments" + + The in-memory fallback keeps counters **per process**. With multiple + workers or replicas each worker has its own counters, so the effective + limit multiplies by the worker count. For production deployments behind + multiple workers, set `REDIS_URL` **or** terminate rate limiting at your + gateway/reverse proxy. + +## Trusted proxies & client IP + +Rate limiting and audit logs key on the **socket peer address** by default. +`X-Forwarded-For` is ignored unless you declare your reverse proxies — +otherwise any client could rotate its rate-limit bucket per request by +sending a fresh header: + +```python +admin = Admin( + app=app, + engine=engine, + secret_key="...", + trusted_proxies=["10.0.0.0/8", "172.17.0.1"], # IPs or CIDR networks +) +``` + +When the direct peer matches `trusted_proxies`, the `X-Forwarded-For` chain +is walked right-to-left, skipping trusted hops, and the first untrusted +address is used as the client IP. + +## 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/__init__.py b/fastapi_admin_kit/__init__.py index 97c7c31..645de87 100644 --- a/fastapi_admin_kit/__init__.py +++ b/fastapi_admin_kit/__init__.py @@ -134,4 +134,4 @@ "configure_notifications", "notifications_router", ] -__version__ = "0.4.0" +__version__ = "0.5.0" 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/admin_database.py b/fastapi_admin_kit/admin/admin_database.py index 83bab8b..d0d0707 100644 --- a/fastapi_admin_kit/admin/admin_database.py +++ b/fastapi_admin_kit/admin/admin_database.py @@ -43,13 +43,31 @@ def __init__( admin_database=self, database_config=database_config ) + @property + def resolved_engine(self) -> Any: + """Return the SQLAlchemy engine, lazily creating it from + ``database_config`` if no engine was passed at construction time. + + This is the property to use everywhere a backend call needs an + engine. Reading the raw ``self.engine`` attribute can be ``None`` + when the project supplied a ``database_config`` instead of an + ``engine`` — calling methods on it then surfaces a confusing + ``AttributeError: 'NoneType' object has no attribute + '_run_ddl_visitor'`` from deep inside SQLAlchemy. + """ + return self._ensure_engine() + def _ensure_engine(self) -> Any: """Create the async engine from ``database_config`` if no engine is set.""" if self.engine is None and self.database_config is not None: self.engine = self.database_config.create_engine() return self.engine - async def _create_tables(self, include_ai_tables: bool = True) -> None: + async def _create_tables( + self, + include_ai_tables: bool = True, + extra_exclude_tables: list[str] | None = None, + ) -> None: """Create all admin database tables (async-safe). If ``use_alembic=True`` (production mode), this method does nothing @@ -59,6 +77,13 @@ async def _create_tables(self, include_ai_tables: bool = True) -> None: ``admin_ai_*`` tables are skipped. This is safe because the AI schemas declare ``relations=[]`` and no FK columns (the "log pattern"), so excluding them cannot break ``create_all`` dependency sorting. + + ``extra_exclude_tables`` lets callers (typically the ``Admin`` setup + path) drop built-in tables that should not be created for this + installation — most notably the default ``admin_users`` / + ``admin_user_roles`` tables when a project supplies a custom + ``auth_model``. Filtering at ``create_all`` time avoids mutating the + shared ``AdminBase.metadata`` (whose FacadeDict is immutable). """ if self.use_alembic: logger.info("use_alembic=True: skipping create_all; schema managed by Alembic") @@ -67,24 +92,42 @@ async def _create_tables(self, include_ai_tables: bool = True) -> None: from fastapi_admin_kit.migrations.models import Base as AdminBase from fastapi_admin_kit.schemas.builtin import AI_TABLE_NAMES + exclude = set(extra_exclude_tables or ()) + def _filtered(metadata: Any) -> Any: - if include_ai_tables: + drop = exclude | (set() if include_ai_tables else AI_TABLE_NAMES) + if not drop: return None # create_all(tables=None) == all tables - return [t for name, t in metadata.tables.items() if name not in AI_TABLE_NAMES] + return [t for name, t in metadata.tables.items() if name not in drop] ai_filtered_admin = _filtered(AdminBase.metadata) ai_filtered_base = _filtered(self.base.metadata) if self.base is not None else None + engine = self.resolved_engine + if engine is None: + raise RuntimeError( + "AdminDatabase has no engine: pass `engine=` or " + "`database_config=` to Admin() so the admin tables can be " + "created. If you manage schema via Alembic, set " + "`use_alembic=True` on Admin() to skip create_all." + ) + await self._run_backend( - self._backend.create_tables, self.engine, AdminBase.metadata, ai_filtered_admin + self._backend.create_tables, + engine, + AdminBase.metadata, + ai_filtered_admin, ) if self.base is not None: await self._run_backend( - self._backend.create_tables, self.engine, self.base.metadata, ai_filtered_base + self._backend.create_tables, + engine, + self.base.metadata, + ai_filtered_base, ) - await self._run_backend(self._backend.auto_migrate, self.engine, AdminBase.metadata) + await self._run_backend(self._backend.auto_migrate, engine, AdminBase.metadata) if self.base is not None: - await self._run_backend(self._backend.auto_migrate, self.engine, self.base.metadata) + await self._run_backend(self._backend.auto_migrate, engine, self.base.metadata) async def _missing_tables(self, ai_enabled: bool, names: list[str]) -> set[str]: """Return the subset of ``names`` whose tables do not exist yet. diff --git a/fastapi_admin_kit/admin/builtin_models.py b/fastapi_admin_kit/admin/builtin_models.py index cf05319..6c0930d 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 @@ -107,7 +108,7 @@ class UserAdmin(ModelAdmin): list_display = ["id", "email", "full_name", "is_superuser", "is_active"] search_fields = ["email", "full_name"] inline_edit = True - exclude = ["hashed_password", "password_changed_at"] + exclude = ["password", "password_changed_at"] extra_fields = [ ExtraField( name="password", @@ -124,14 +125,40 @@ 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 + secret columns (``password`` etc.) directly. + """ + if self._actor_is_superuser(request): + return data + from fastapi_admin_kit.inspection.types import PRIVILEGED_ASSIGNMENT_FIELDS + + for field in SENSITIVE_FIELDS | PRIVILEGED_ASSIGNMENT_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 password injection is removed, + # then derive 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) + data["password"] = User.hash_password(password) else: - data["hashed_password"] = "" + data["password"] = "" return data def validate_create(self, data, request=None): @@ -162,6 +189,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/admin/core.py b/fastapi_admin_kit/admin/core.py index ab15367..05e5200 100644 --- a/fastapi_admin_kit/admin/core.py +++ b/fastapi_admin_kit/admin/core.py @@ -18,11 +18,13 @@ from fastapi_admin_kit.admin.admin_database import AdminDatabase from fastapi_admin_kit.admin.admin_router import AdminRouter from fastapi_admin_kit.admin.admin_template import AdminTemplate +from fastapi_admin_kit.auth.backend import BuiltinAuthBackend from fastapi_admin_kit.config import ( AIChatConfig, AuditConfig, AuthConfig, BehaviorConfig, + CacheConfig, DatabaseConfig, NavConfig, StorageConfig, @@ -56,6 +58,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 +94,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 @@ -195,10 +200,14 @@ def __init__( admin_path: str = "/admin", secret_key: str = "", auth_model: type | None = None, - auth_backend: AuthBackend | None = None, + auth_backend: AuthBackend | None = BuiltinAuthBackend(), session_cookie_name: str = "admin_session", session_secure: bool = True, session_samesite: str = "strict", + access_token_ttl: int = 600, + api_token_middleware: bool = True, + api_token_strict: bool = False, + trusted_proxies: list[str] | None = None, seed_roles: list[SeedRole] | None = None, seed_roles_overwrite: bool = False, superuser_emails: list[str] | None = None, @@ -246,6 +255,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 @@ -269,6 +281,14 @@ def __init__( app.add_middleware(CSRFMiddleware) self._csrf_middleware_added = True + # API bearer-token pre-validation. Added BEFORE SessionMiddleware + # so it runs inside it (Starlette: last added = outermost) and can + # use the per-request DB session to resolve the live user. + from fastapi_admin_kit.api.middleware import AccessTokenMiddleware + + app.add_middleware(AccessTokenMiddleware) + self._api_token_middleware_added = True + # Register the per-request session + audit-context middlewares here # (at construction time) rather than in ``setup()``. Starlette builds # ``app.middleware_stack`` on the *first* scope it receives — which is @@ -288,6 +308,7 @@ def __init__( self._audit_middleware_added = True else: self._csrf_middleware_added = False + self._api_token_middleware_added = False self._session_middleware_added = False self._audit_middleware_added = False @@ -338,6 +359,10 @@ def __init__( session_secure=session_secure, superuser_emails=superuser_emails, session_samesite=session_samesite, + access_token_ttl=access_token_ttl, + api_token_middleware=api_token_middleware, + api_token_strict=api_token_strict, + trusted_proxies=trusted_proxies, ), audit=AuditConfig(audit_retention_days=audit_retention_days), behavior=BehaviorConfig( @@ -372,6 +397,7 @@ def __init__( ".webp", ], ), + cache=CacheConfig(enabled=cache_enabled, ttl=cache_ttl), ) else: config = _merge_legacy_kwargs_into_config( @@ -414,6 +440,10 @@ def __init__( session_secure=session_secure, superuser_emails=superuser_emails, session_samesite=session_samesite, + access_token_ttl=access_token_ttl, + api_token_middleware=api_token_middleware, + api_token_strict=api_token_strict, + trusted_proxies=trusted_proxies, ), audit=dict(audit_retention_days=audit_retention_days), behavior=dict( @@ -431,7 +461,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 +503,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" @@ -497,6 +541,22 @@ def __init__( ): backend_database._admin_database = database + # Inject backend into the auth backend so BuiltinAuthBackend can build + # queries via QueryBackend instead of importing sqlalchemy directly. + _auth_backend = getattr(getattr(self, "config", None), "auth", None) + _auth_backend = getattr(_auth_backend, "auth_backend", None) if _auth_backend else None + if _auth_backend is not None: + for attr, value in ( + ("_backend", self.backend), + ("backend", self.backend), + ("_query_backend", getattr(self.backend, "query", None)), + ("query_backend", getattr(self.backend, "query", None)), + ): + try: + setattr(_auth_backend, attr, value) + except Exception: + pass + # Inject backend's introspection adapter into the registry's ModelInspector self.registry.inspector._adapter = self.backend.introspection @@ -521,6 +581,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 +725,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 ( @@ -675,6 +745,18 @@ async def setup(self, app: FastAPI | None = None) -> None: except RuntimeError: pass # Already started — middleware was added in __init__ + # Add API bearer-token middleware if not already added in __init__ + if not getattr(self, "_api_token_middleware_added", False): + from fastapi_admin_kit.api.middleware import AccessTokenMiddleware + + try: + app.add_middleware(AccessTokenMiddleware) + self._api_token_middleware_added = True + except RuntimeError: + app.middleware_stack = None + app.add_middleware(AccessTokenMiddleware) + self._api_token_middleware_added = True + # Add per-request session middleware if app is not None and not getattr(self, "_session_middleware_added", False): from fastapi_admin_kit.db import SessionMiddleware @@ -719,9 +801,11 @@ async def setup(self, app: FastAPI | None = None) -> None: self.config.auth.validate_auth_model() # 2. Database tables should be created via Alembic migrations - skip_create_tables = os.environ.get("SKIP_CREATE_TABLES", "false").lower() == "true" - if not skip_create_tables: - await self.database._create_tables(include_ai_tables=self._ai_enabled) + # ``create_tables()`` is the public entry point that projects can + # also call from their lifespan before ``admin.setup(app)`` — + # using it here keeps both code paths in sync (and is the only + # way the custom-auth_model skip is applied). + await self.create_tables() # 2.1 Preflight: if AI is enabled but the tables are genuinely missing # (Alembic / SKIP_CREATE_TABLES mode), warn loudly but never block boot. @@ -891,6 +975,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() @@ -908,6 +1024,10 @@ def _wire_app_state(self, app: FastAPI) -> None: "dark_mode_default": self.config.ui.dark_mode_default, "per_page_default": self.config.ui.per_page_default, "session_ttl": self.config.auth.session_ttl, + "access_token_ttl": self.config.auth.access_token_ttl, + "api_token_middleware": self.config.auth.api_token_middleware, + "api_token_strict": self.config.auth.api_token_strict, + "trusted_proxies": self.config.auth.trusted_proxies, "audit_retention_days": self.config.audit.audit_retention_days, "dashboard_stats": self.config.behavior.dashboard_stats, "dashboard_charts": self.config.behavior.dashboard_charts, @@ -930,9 +1050,29 @@ def _wire_app_state(self, app: FastAPI) -> None: # backend-agnostic SessionBackend, exactly like the per-request one. db_session = session_factory() - # Inject auth_model into the backend if provided - if self.config.auth.auth_backend is not None and self.config.auth.auth_model is not None: - self.config.auth.auth_backend._auth_model = self.config.auth.auth_model + # Inject auth_model and backend into the auth backend for ORM-agnostic queries. + # BuiltinAuthBackend uses the QueryBackend (select/where/options) and the + # DatabaseBackend's session_adapter_class via as_session_backend, so it + # must not import sqlalchemy directly. + if self.config.auth.auth_backend is not None: + if self.config.auth.auth_model is not None: + try: + self.config.auth.auth_backend._auth_model = self.config.auth.auth_model + except AttributeError: + pass + # Wire the composite backend and its query adapter — supports both + # BuiltinAuthBackend (stores _backend/_query_backend) and any + # custom backend that exposes the same attributes. + for attr, value in ( + ("_backend", self.backend), + ("backend", self.backend), + ("_query_backend", getattr(self.backend, "query", None)), + ("query_backend", getattr(self.backend, "query", None)), + ): + try: + setattr(self.config.auth.auth_backend, attr, value) + except Exception: + pass state = AdminState( engine=engine, @@ -1210,7 +1350,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) @@ -1293,7 +1433,6 @@ def _register_builtin_models(self) -> None: ) builtin_models = [ - (User, UserAdmin), (Role, RoleAdmin), # (RefreshToken, RefreshTokenAdmin), (Permission, PermissionAdmin), @@ -1303,6 +1442,17 @@ def _register_builtin_models(self) -> None: (AuditLog, AuditLogAdmin), ] + # When a custom auth_model is provided, the built-in ``User`` model is + # not registered: the project supplies its own user model and + # ``UserAdmin`` (the built-in CRUD) does not match it. Skipping here + # also keeps the registry consistent with the migration metadata, from + # which the built-in ``admin_users`` table is removed in + # ``_adapt_builtin_user_id_columns`` when a custom auth_model is set. + from fastapi_admin_kit.migrations.models import User as BuiltinUser + + if self.config.auth.auth_model is None or self.config.auth.auth_model is BuiltinUser: + builtin_models.insert(0, (User, UserAdmin)) + # Notification models are exposed under the "notifications" sidebar # group. Register them only when notifications are enabled so the # group never appears when enable_notification=False. @@ -1327,6 +1477,142 @@ def _register_builtin_models(self) -> None: if model.__tablename__ not in self.registry._models: self.registry.register(model, admin_class) + def _builtin_user_tables_to_skip(self) -> tuple[str, ...]: + """Return the names of built-in tables that should NOT be created when + a custom ``auth_model`` is configured. + + Returns an empty tuple when the built-in ``User`` is in use (default + installation) or when no ``auth_model`` is configured. + """ + from fastapi_admin_kit.migrations.models import User as BuiltinUser + + auth_model = self.config.auth.auth_model + if auth_model is None or auth_model is BuiltinUser: + return () + return ("admin_users", "admin_user_roles") + + async def create_tables(self) -> None: + """Create all admin database tables, correctly handling a custom + ``auth_model``. + + This is the public API projects should use in their ``lifespan`` (or + startup hook) instead of calling + ``AdminBase.metadata.create_all`` directly. Calling + ``AdminBase.metadata.create_all`` directly bypasses the + custom-auth_model logic and will create the default + ``admin_users`` / ``admin_user_roles`` tables even when a project + supplies its own user model. + + What this method does, in order: + + 1. Validate the configured ``auth_model`` (raises ``ConfigError`` if + it does not satisfy :class:`AdminUserProtocol`). + 2. Mirror the auth model's primary-key type onto the built-in + log-pattern ``user_id`` columns (e.g. a UUID PK becomes a UUID + ``user_id`` so the column type matches across tables). + 3. Call ``AdminDatabase._create_tables(extra_exclude_tables=...)`` + with the built-in user tables filtered out when a custom + ``auth_model`` is configured. Honors ``SKIP_CREATE_TABLES=true`` + and the AI / notification feature flags exactly like + ``Admin.setup()`` does. + """ + self.config.auth.validate_auth_model() + skip_create_tables = os.environ.get("SKIP_CREATE_TABLES", "false").lower() == "true" + if skip_create_tables: + logger.info("SKIP_CREATE_TABLES=true: skipping admin table creation") + return + self._adapt_builtin_user_id_columns() + extra_exclude = list(self._builtin_user_tables_to_skip()) + if extra_exclude: + logger.info( + "Custom auth_model=%s configured: skipping built-in admin " + "tables %s at create_all (project supplies its own user " + "table).", + self.config.auth.auth_model, + extra_exclude, + ) + await self.database._create_tables( + include_ai_tables=self._ai_enabled, + extra_exclude_tables=extra_exclude or None, + ) + + def _adapt_builtin_user_id_columns(self) -> None: + """ + Adapt the built-in log-pattern ``user_id`` columns in ``AdminBase.metadata`` + to match the primary-key type of the configured ``auth_model`` (see + ``schemas/builtin.py``). When a project supplies a custom + ``auth_model`` whose primary key differs from the built-in ``User`` + (e.g. a ``UUID`` PK), these columns are retyped to match so that + ``metadata.create_all`` emits the correct DDL. + + This is a no-op for the default installation (``auth_model is None`` + or the built-in ``User``), which preserves backward compatibility + and never alters existing schemas/migrations. + + When a custom ``auth_model`` is provided, the built-in ``admin_users`` + table is also removed from ``AdminBase.metadata`` (and the + ``admin_user_roles`` junction) so that ``create_all`` does not emit + DDL for the default schema. The custom user model — which must + already be registered on a ``Base`` and present in the project's + metadata — becomes the sole source of truth for the user table. + """ + from fastapi_admin_kit.migrations.models import User as BuiltinUser + + auth_model = self.config.auth.auth_model + if auth_model is None: + return + if auth_model is BuiltinUser: + return + + metadata = BuiltinUser.__table__.metadata + + from sqlalchemy import inspect as sa_inspect + + pk_cols = sa_inspect(auth_model).primary_key + if not pk_cols: + return + pk_col = pk_cols[0] + new_type = pk_col.type + logger.debug( + "Adapting builtin user_id columns: auth_model=%s pk_col=%s pk_type=%r", + auth_model, + pk_col.name, + new_type, + ) + + # user_email columns intentionally stay string (they store emails). + log_tables = [ + "admin_audit_log", + "admin_user_permissions", + "admin_refresh_tokens", + "admin_user_totp", + "admin_notifications", + "admin_notification_preferences", + "admin_notification_logs", + "admin_ai_usage_log", + "admin_ai_conversations", + ] + for table_name in log_tables: + table = metadata.tables.get(table_name) + if table is None or "user_id" not in table.c: + continue + col = table.c["user_id"] + logger.debug( + " %s.user_id: %r (%s) -> %r (%s)", + table_name, + col.type, + type(col.type).__name__, + new_type, + type(new_type).__name__, + ) + col.type = new_type + + # Note: the built-in ``admin_users`` and ``admin_user_roles`` tables + # are excluded from ``create_all`` at the call site in ``setup()`` via + # ``AdminDatabase._create_tables(extra_exclude_tables=...)`` — we do + # NOT remove them from ``AdminBase.metadata`` here because the + # ``FacadeDict`` exposed by ``Base.metadata`` is immutable. + # ------------------------------------------------------------------ # AI Setup # ------------------------------------------------------------------ diff --git a/fastapi_admin_kit/admin/decorators.py b/fastapi_admin_kit/admin/decorators.py index 13b9582..bf23266 100644 --- a/fastapi_admin_kit/admin/decorators.py +++ b/fastapi_admin_kit/admin/decorators.py @@ -26,6 +26,7 @@ class EndpointOptions: response_description: str = "" permission: str | None = None include_in_schema: bool = True + allow_anonymous: bool = False def __call__(self, func: Callable) -> Callable: func._admin_endpoint = self @@ -109,6 +110,7 @@ def endpoint( response_description: str = "", permission: str | None = None, include_in_schema: bool = True, + allow_anonymous: bool = False, ) -> EndpointOptions: """Decorator to register a custom FastAPI endpoint on a ModelAdmin. @@ -117,6 +119,11 @@ def endpoint( FastAPI configuration support (path, methods, tags, dependencies, status code, response model, ...). + Secure by default (S17): when neither ``permission`` nor + ``allow_anonymous=True`` is given, the endpoint requires an + authenticated user with ``view`` permission on the model. Pass + ``allow_anonymous=True`` to expose a genuinely public endpoint. + Usage:: from fastapi_admin_kit import endpoint @@ -145,4 +152,5 @@ async def health_check(self, request): response_description=response_description, permission=permission, include_in_schema=include_in_schema, + allow_anonymous=allow_anonymous, ) diff --git a/fastapi_admin_kit/api/auth.py b/fastapi_admin_kit/api/auth.py index 56bc52f..35f6dc7 100644 --- a/fastapi_admin_kit/api/auth.py +++ b/fastapi_admin_kit/api/auth.py @@ -18,12 +18,31 @@ TokenResponse, ) from fastapi_admin_kit.api.security import basic_scheme, bearer_scheme -from fastapi_admin_kit.auth.ratelimit import RateLimiter, check_rate_limit +from fastapi_admin_kit.auth.proxy import get_client_ip from fastapi_admin_kit.db import get_db_session router = APIRouter(prefix="/auth", tags=["api-auth"]) -_api_rate_limiter = RateLimiter(max_attempts=10, window_seconds=900) +# Rate limits (S11). The token endpoint counts FAILED attempts per +# (client IP, email) so legitimate users behind shared NATs are not locked +# out by other people's typos; refresh/logout count every request per IP +# because they are unauthenticated DB-touching endpoints. +TOKEN_RATE_LIMIT = 10 +TOKEN_RATE_WINDOW = 900 +REFRESH_RATE_LIMIT = 60 +REFRESH_RATE_WINDOW = 300 +LOGOUT_RATE_LIMIT = 30 +LOGOUT_RATE_WINDOW = 300 + +DEFAULT_ACCESS_TOKEN_TTL = 600 +"""Default access-token lifetime (seconds). + +Access tokens are intentionally short-lived: revocation on logout is +best-effort (the refresh token is revoked server-side; a held access +token simply expires within this window). Password changes kill +outstanding tokens immediately via the ``iat`` vs ``password_changed_at`` +check — no server-side token store needed. +""" def _get_secret_key(request: Request) -> str: @@ -48,9 +67,21 @@ def _get_secret_key(request: Request) -> str: def _get_token_ttl(request: Request) -> int: - """Get access token TTL in seconds from admin config.""" + """Get the access-token TTL in seconds. + + Resolution order: + + 1. ``access_token_ttl`` — dedicated knob (recommended). Defaults to + 600 s (10 min): short enough that a stolen bearer token expires + quickly, while ``/api/auth/refresh`` keeps sessions alive. + 2. ``session_ttl`` — legacy fallback for apps that configured it + before the dedicated knob existed. + """ config = getattr(request.app.state, "admin_config", {}) - return config.get("session_ttl", 1800) + ttl = config.get("access_token_ttl") + if ttl is None: + ttl = config.get("session_ttl", DEFAULT_ACCESS_TOKEN_TTL) + return int(ttl) def _get_refresh_ttl() -> int: @@ -121,9 +152,15 @@ def create_access_token( permissions: dict[str, list[str]] | None = None, expires_delta: timedelta | None = None, ) -> str: - """Create a JWT access token with embedded roles and permissions.""" + """Create a short-lived JWT access token. + + The token carries a ``jti`` (for audit correlation) and an ``iat`` + used to reject tokens minted before the user's last password change. + Authorization is *not* trusted from this token — permission checks + always resolve live DB state (see ``api/deps.py``). + """ now = datetime.now(UTC) - expire = now + (expires_delta or timedelta(minutes=30)) + expire = now + (expires_delta or timedelta(seconds=DEFAULT_ACCESS_TOKEN_TTL)) jti = str(uuid.uuid4()) role_names = [] @@ -147,6 +184,39 @@ def create_access_token( return jwt.encode(payload, secret_key, algorithm="HS256") +def token_predates_password_change(payload: dict[str, Any], user: Any) -> bool: + """True when the token was minted before the user's last password change. + + Used to invalidate outstanding access tokens immediately after a + credential rotation. Tokens without ``iat`` (legacy/foreign minters) + fail open — they still expire within the short TTL. + """ + from datetime import datetime as _dt + + iat = payload.get("iat") + pwd_changed_at = getattr(user, "password_changed_at", None) + if iat is None or pwd_changed_at is None: + return False + + if isinstance(iat, int | float): + try: + iat_dt = _dt.fromtimestamp(iat, tz=UTC) + except (OverflowError, OSError, ValueError): + return False + elif isinstance(iat, _dt): + iat_dt = iat + else: + return False + + if isinstance(pwd_changed_at, _dt): + if pwd_changed_at.tzinfo is None: + pwd_changed_at = pwd_changed_at.replace(tzinfo=UTC) + else: + return False + + return iat_dt < pwd_changed_at + + def decode_access_token(token: str, secret_key: str) -> dict[str, Any] | None: """Decode and validate a JWT access token. Returns payload or None.""" try: @@ -178,7 +248,20 @@ async def obtain_token( else: raise HTTPException(status_code=422, detail="Credentials required.") - check_rate_limit(_api_rate_limiter, email) + # Rate-limit failed attempts per (client IP, email). The client IP is + # resolved through the trusted-proxy helper: spoofed X-Forwarded-For + # headers cannot rotate the bucket (S11). + from fastapi_admin_kit.redis import resolve_rate_guard + + client_ip = get_client_ip(request) + token_guard = await resolve_rate_guard( + request, + limit=TOKEN_RATE_LIMIT, + window=TOKEN_RATE_WINDOW, + slot="_api_token_rate_limiter", + ) + token_key = f"{client_ip}|{email.strip().lower()}" + await token_guard.check(token_key) auth_backend = getattr(request.app.state, "admin_auth_backend", None) if auth_backend is None: @@ -188,12 +271,18 @@ async def obtain_token( if db_session is None: raise HTTPException(status_code=500, detail="Database session not available.") - user = await auth_backend.authenticate(email, password, db_session) + query_adapter = getattr(request.app.state, "admin_query_adapter", None) + try: + user = await auth_backend.authenticate( + email, password, db_session, query_adapter=query_adapter + ) + except TypeError: + user = await auth_backend.authenticate(email, password, db_session) if user is None: - _api_rate_limiter.record_attempt(email) + await token_guard.record_failure(token_key) raise HTTPException(status_code=401, detail="Invalid credentials.") - _api_rate_limiter.reset(email) + await token_guard.reset(token_key) secret_key = _get_secret_key(request) ttl = _get_token_ttl(request) @@ -230,6 +319,20 @@ async def refresh_token( body: RefreshRequest, ) -> RefreshResponse: """POST /api/auth/refresh — exchange refresh token for new access token.""" + # Unauthenticated endpoint that hits the DB and rotates tokens — + # rate-limit every request per client IP (S11). + from fastapi_admin_kit.redis import resolve_rate_guard + + refresh_guard = await resolve_rate_guard( + request, + limit=REFRESH_RATE_LIMIT, + window=REFRESH_RATE_WINDOW, + slot="_api_refresh_rate_limiter", + ) + refresh_key = f"ip:{get_client_ip(request)}" + await refresh_guard.check(refresh_key) + await refresh_guard.record_failure(refresh_key) + db_session = get_db_session(request) if db_session is None: raise HTTPException(status_code=500, detail="Database session not available.") @@ -300,7 +403,24 @@ async def api_logout( request: Request, body: RefreshRequest | None = None, ) -> dict[str, str]: - """POST /api/auth/logout — revoke refresh token.""" + """POST /api/auth/logout — revoke the refresh token. + + The presented access token is not server-revoked: it expires within + the short ``access_token_ttl`` window. Clients must discard it. + """ + # Unauthenticated endpoint — rate-limit every request per client IP (S11). + from fastapi_admin_kit.redis import resolve_rate_guard + + logout_guard = await resolve_rate_guard( + request, + limit=LOGOUT_RATE_LIMIT, + window=LOGOUT_RATE_WINDOW, + slot="_api_logout_rate_limiter", + ) + logout_key = f"ip:{get_client_ip(request)}" + await logout_guard.check(logout_key) + await logout_guard.record_failure(logout_key) + if body and body.refresh_token: db_session = get_db_session(request) if db_session: @@ -327,7 +447,13 @@ async def get_current_user_info( request: Request, _: Any = Depends(bearer_scheme), ) -> dict[str, Any]: - """GET /api/auth/me — return current user info from JWT (no DB hit).""" + """GET /api/auth/me — current user info resolved from the live database. + + The JWT only authenticates identity; the response reflects current DB + state (email, name, superuser flag, roles). Deactivation, deletion, or + a password change after the token was minted invalidates it + immediately. + """ auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing or invalid Authorization header.") @@ -338,10 +464,34 @@ async def get_current_user_info( if payload is None: raise HTTPException(status_code=401, detail="Invalid or expired token.") + sub = payload.get("sub") + if sub is None: + raise HTTPException(status_code=401, detail="Invalid or expired token.") + try: + user_id: int | str = int(sub) + except (TypeError, ValueError): + raise HTTPException(status_code=401, detail="Invalid or expired token.") from None + + # Resolve through the AuthBackend seam: honours BYO user models and + # returns None for deleted/deactivated accounts. + from fastapi_admin_kit.auth.identity import resolve_user + + user = await resolve_user(request, user_id) + if user is None: + raise HTTPException(status_code=401, detail="Account not found or inactive.") + + if token_predates_password_change(payload, user): + raise HTTPException(status_code=401, detail="Token has been revoked.") + + try: + role_names = [getattr(r, "name", "") for r in getattr(user, "roles", [])] + except Exception: # noqa: BLE001 — roles may be unavailable on BYO models + role_names = payload.get("roles", []) + return { - "user_id": payload.get("sub"), - "email": payload.get("email"), - "full_name": payload.get("full_name"), - "roles": payload.get("roles", []), - "is_superuser": payload.get("is_superuser", False), + "user_id": str(user_id), + "email": getattr(user, "email", None), + "full_name": getattr(user, "full_name", None), + "roles": role_names, + "is_superuser": bool(getattr(user, "is_superuser", False)), } diff --git a/fastapi_admin_kit/api/deps.py b/fastapi_admin_kit/api/deps.py index eb2dde3..c60ab06 100644 --- a/fastapi_admin_kit/api/deps.py +++ b/fastapi_admin_kit/api/deps.py @@ -1,10 +1,14 @@ -"""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. + +``AccessTokenMiddleware`` (see ``api/middleware.py``) pre-validates bearer +tokens for ``/api/*`` routes and stashes the decoded payload on +``request.state.admin_jwt_payload``; these dependencies reuse it instead of +decoding twice. """ from __future__ import annotations @@ -13,14 +17,23 @@ from fastapi import HTTPException, Request -from fastapi_admin_kit.api.auth import _get_secret_key, decode_access_token +from fastapi_admin_kit.api.auth import ( + _get_secret_key, + decode_access_token, + token_predates_password_change, +) async def get_api_current_user(request: Request) -> dict[str, Any]: """Decode JWT and return user payload (no DB hit). - Raises 401 if token is missing or invalid. + Reuses the payload stashed by ``AccessTokenMiddleware`` when present. + Raises 401 if the token is missing or invalid. """ + cached = getattr(request.state, "admin_jwt_payload", None) + if cached is not None: + return cached + auth_header = request.headers.get("Authorization", "") if not auth_header.startswith("Bearer "): raise HTTPException(status_code=401, detail="Missing or invalid Authorization header.") @@ -33,48 +46,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 +77,64 @@ 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 + # A password change after the token was minted kills it immediately. + if token_predates_password_change(user, resolved): + raise HTTPException(status_code=401, detail="Token has been revoked.") - raise HTTPException( - status_code=403, - detail=f"You do not have permission to {action} {table_name}.", + session = get_db_session(request) + if session is None: + raise HTTPException(status_code=503, detail="Database unavailable.") + + 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 token_predates_password_change(user, resolved): + raise HTTPException(status_code=401, detail="Token has been revoked.") + + 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/middleware.py b/fastapi_admin_kit/api/middleware.py new file mode 100644 index 0000000..b3d2376 --- /dev/null +++ b/fastapi_admin_kit/api/middleware.py @@ -0,0 +1,131 @@ +"""Access-token middleware — pre-validate bearer JWTs on API routes. + +Authenticates (never authorizes) ``Authorization: Bearer`` tokens for +configured path prefixes before the request reaches any route handler: + +- an invalid, expired, deactivated-user, or password-stale token is + rejected with 401 immediately; +- the decoded payload is cached on ``request.state.admin_jwt_payload`` + and the live user on ``request.state.admin_user`` so per-route + dependencies (:func:`api.deps.require_api_permission`) don't repeat + the work. + +Modes +----- + +- **lenient** (default, ``api_token_strict=False``): only tokens that are + *presented* are validated; requests without the header pass through and + route dependencies decide. Backwards compatible — public/custom API + routes keep working. +- **strict** (``api_token_strict=True``): every scoped path requires a + valid bearer token except the exempt paths (token/refresh/logout by + default). Use when the whole ``/api`` surface is first-party. + +The middleware reads its knobs from ``app.state.admin_config`` at request +time (``api_token_strict``, ``api_token_middleware``), so it can be added +before ``setup()`` populates state. +""" + +from __future__ import annotations + +from typing import Any + +from starlette.middleware.base import ( + BaseHTTPMiddleware, + RequestResponseEndpoint, +) +from starlette.requests import Request as StarletteRequest +from starlette.responses import JSONResponse, Response + +DEFAULT_API_PREFIXES = ("/api/",) +"""Path prefixes the middleware scopes to.""" + +DEFAULT_EXEMPT_PATHS = frozenset( + { + "/api/auth/token", + "/api/auth/refresh", + "/api/auth/logout", + } +) +"""Endpoints that must be reachable without a bearer token.""" + + +def _unauthorized(detail: str) -> JSONResponse: + return JSONResponse( + status_code=401, + content={"detail": detail}, + headers={"WWW-Authenticate": "Bearer"}, + ) + + +class AccessTokenMiddleware(BaseHTTPMiddleware): + """Validate bearer access tokens for API routes (see module docstring).""" + + def __init__( + self, + app: Any, # noqa: ANN401 — ASGI app + api_prefixes: tuple[str, ...] = DEFAULT_API_PREFIXES, + exempt_paths: frozenset[str] | set[str] = DEFAULT_EXEMPT_PATHS, + ) -> None: + super().__init__(app) + self.api_prefixes = api_prefixes + self.exempt_paths = exempt_paths + + def _in_scope(self, path: str) -> bool: + return any(path.startswith(prefix) for prefix in self.api_prefixes) + + async def dispatch( + self, request: StarletteRequest, call_next: RequestResponseEndpoint + ) -> Response: + config = getattr(request.app.state, "admin_config", {}) or {} + + if not config.get("api_token_middleware", True): + return await call_next(request) + + path = request.url.path + if not self._in_scope(path): + return await call_next(request) + + auth_header = request.headers.get("Authorization", "") + if not auth_header.startswith("Bearer "): + # No token presented: strict mode rejects, lenient mode lets + # route dependencies decide. + if config.get("api_token_strict", False) and path not in self.exempt_paths: + return _unauthorized("Authentication required.") + return await call_next(request) + + from fastapi_admin_kit.api.auth import ( + _get_secret_key, + decode_access_token, + token_predates_password_change, + ) + from fastapi_admin_kit.auth.identity import resolve_user + + try: + secret_key = _get_secret_key(request) + except Exception: # noqa: BLE001 — app misconfiguration surfaces below + return await call_next(request) + + payload = decode_access_token(auth_header[7:], secret_key) + if payload is None: + return _unauthorized("Invalid or expired token.") + + sub = payload.get("sub") + user_id: int | str | None = None + if sub is not None: + try: + user_id = int(sub) + except (TypeError, ValueError): + user_id = None + + user = await resolve_user(request, user_id) if user_id is not None else None + if user is None: + return _unauthorized("Account not found or inactive.") + + if token_predates_password_change(payload, user): + return _unauthorized("Token has been revoked.") + + # Share the work with per-route dependencies. + request.state.admin_jwt_payload = payload + + return await call_next(request) diff --git a/fastapi_admin_kit/api/schema_generator.py b/fastapi_admin_kit/api/schema_generator.py index 24f27e8..19c7e45 100644 --- a/fastapi_admin_kit/api/schema_generator.py +++ b/fastapi_admin_kit/api/schema_generator.py @@ -87,6 +87,23 @@ def _collect_relationships(registered: Any) -> list[Any]: return relationships +def _add_extra_fields(admin: Any, fields: dict[str, Any], *, required_on_create: bool) -> None: + """Add ModelAdmin.extra_fields (e.g. ``password``) to a request schema. + + The HTML form exposes these virtual fields; the JSON API must accept + them too so create/update validation behaves identically on both + transports. + """ + readonly = set(admin.readonly_fields or []) + for extra in getattr(admin, "extra_fields", None) or []: + if extra.name in fields or extra.name in readonly: + continue + if required_on_create and extra.required_on_create: + fields[extra.name] = (str | None, Field(...)) + else: + fields[extra.name] = (str | None, Field(default=None)) + + def _relationship_python_type(rel: Any) -> type: """Get the Python type for a relationship field in a request schema. @@ -127,6 +144,8 @@ def build_create_schema(registered: Any) -> type[BaseModel]: python_type = _relationship_python_type(rel) fields[rel.name] = (python_type | None, Field(default=None)) + _add_extra_fields(admin, fields, required_on_create=True) + model_name = f"{registered.verbose_name.replace(' ', '')}Create" return create_model(model_name, __config__=None, **fields) @@ -155,13 +174,17 @@ def build_update_schema(registered: Any) -> type[BaseModel]: python_type = _relationship_python_type(rel) fields[rel.name] = (python_type | None, Field(default=None)) + _add_extra_fields(admin, fields, required_on_create=False) + model_name = f"{registered.verbose_name.replace(' ', '')}Update" return create_model(model_name, __config__=None, **fields) 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/audit/middleware.py b/fastapi_admin_kit/audit/middleware.py index 540a25b..662d3d8 100644 --- a/fastapi_admin_kit/audit/middleware.py +++ b/fastapi_admin_kit/audit/middleware.py @@ -7,6 +7,7 @@ from starlette.responses import Response from fastapi_admin_kit.audit.context import clear_audit_context, set_audit_context +from fastapi_admin_kit.auth.proxy import get_client_ip class AuditContextMiddleware(BaseHTTPMiddleware): @@ -17,13 +18,17 @@ class AuditContextMiddleware(BaseHTTPMiddleware): :func:`fastapi_admin_kit.auth.identity.resolve_user` once the auth dependency resolves the current user — which always happens before any ``session.commit()`` that would trigger audit listeners. + + The IP is resolved through the shared trusted-proxy helper: without a + configured ``trusted_proxies`` list the socket peer address is used and + ``X-Forwarded-For`` is ignored (attacker-controlled otherwise). """ async def dispatch(self, request: Request, call_next) -> Response: context_data: dict = {} if request.client is not None: - context_data["ip_address"] = request.client.host + context_data["ip_address"] = get_client_ip(request) user_agent = request.headers.get("user-agent") if user_agent: diff --git a/fastapi_admin_kit/auth/backend.py b/fastapi_admin_kit/auth/backend.py index 0c218ea..ac7292f 100644 --- a/fastapi_admin_kit/auth/backend.py +++ b/fastapi_admin_kit/auth/backend.py @@ -40,7 +40,48 @@ async def on_logout(self, user_id: int | str | None = None) -> None: class BuiltinAuthBackend(AuthBackend): - """Default backend that works with the built-in ``User`` model or custom auth_model.""" + """Default backend that works with the built-in ``User`` model or custom auth_model. + + Implements :class:`AuthBackend` exclusively through the multi-ORM seam: + + - :class:`QueryBackend` builds ``select`` / ``where`` / ``options`` queries + (``SqlAlchemyQueryAdapter`` or ``MemoryQueryAdapter``). + - :class:`SessionBackend` executes them via ``scalar_one_or_none`` and + wraps the raw DB session via :func:`as_session_backend` using the + configured ``DatabaseBackend``'s ``session_adapter_class``. + + No ``sqlalchemy`` imports appear at query-build or execution time; the + backend adapters encapsulate all ORM specifics. This keeps + ``BuiltinAuthBackend`` usable with ``InMemoryBackend`` and any future ODM + backend. + """ + + def __init__( + self, + auth_model: type | None = None, + backend: Any | None = None, + query_backend: Any | None = None, + ) -> None: + super().__init__(auth_model=auth_model) + self._backend = backend + self._query_backend = query_backend + + # Backwards-compatible aliases — some call sites use ``.backend`` / ``.query_backend`` + @property + def backend(self) -> Any | None: + return self._backend + + @backend.setter + def backend(self, value: Any | None) -> None: + self._backend = value + + @property + def query_backend(self) -> Any | None: + return self._query_backend + + @query_backend.setter + def query_backend(self, value: Any | None) -> None: + self._query_backend = value def _get_model(self) -> type: if self._auth_model is not None: @@ -49,50 +90,131 @@ def _get_model(self) -> type: return User + def _resolve_query_backend(self, explicit: Any | None = None) -> Any: + """Return the :class:`QueryBackend` to use for this call. + + Resolution order: + + 1. Explicit ``query_adapter`` passed to the method. + 2. ``self._query_backend`` set at construction / injected by :class:`Admin`. + 3. ``self._backend.query`` from the composite backend. + 4. Fallback ``SqlAlchemyQueryAdapter`` for legacy call sites / tests that + instantiate ``BuiltinAuthBackend()`` directly without an Admin. + """ + if explicit is not None: + return explicit + if self._query_backend is not None: + return self._query_backend + if self._backend is not None: + qb = getattr(self._backend, "query", None) + if qb is not None: + return qb + from fastapi_admin_kit.backends.sqlalchemy import SqlAlchemyQueryAdapter + + return SqlAlchemyQueryAdapter() + + def _resolve_session(self, session: Any) -> Any: + """Wrap *session* via :func:`as_session_backend` using the DB backend.""" + if self._backend is not None: + return as_session_backend(session, backend=self._backend) + return as_session_backend(session) + async def authenticate( self, credential: str, password: str, session: Any, login_field: str = "email", + query_adapter: Any | None = None, + query_backend: Any | None = None, + **kwargs: Any, ) -> AdminUserProtocol | None: - from sqlalchemy import select - from sqlalchemy.orm import selectinload - - session = as_session_backend(session) + # Accept both ``query_adapter`` and legacy ``query_backend`` kwarg names + qb = query_adapter if query_adapter is not None else query_backend + if qb is None: + qb = kwargs.get("query_adapter") or kwargs.get("query_backend") + query_backend_resolved = self._resolve_query_backend(qb) + session = self._resolve_session(session) model = self._get_model() field = getattr(model, login_field, None) + print("model: ", model) + print("field: ", field) if field is None: field = getattr(model, "email", None) if field is None: return None - query = select(model).where(field == credential, model.is_active.is_(True)) - # Eagerly load roles if the model has a roles relationship + # Build query via QueryBackend — backend agnostic (Memory vs SQLAlchemy) + query = query_backend_resolved.select(model) + is_active_col = getattr(model, "is_active", None) + if is_active_col is not None: + query = query_backend_resolved.where( + query, + field == credential, + is_active_col == True, # noqa: E712 + ) + else: + query = query_backend_resolved.where(query, field == credential) + + # Eagerly load roles if the model has a roles relationship. + # For SQLAlchemy this is a selectinload option; for Memory it is a no-op. if hasattr(model, "roles"): - query = query.options(selectinload(model.roles)) + try: + if query_backend_resolved.__class__.__name__ == "SqlAlchemyQueryAdapter": + from sqlalchemy.orm import selectinload - user = await session.scalar_one_or_none(query) + query = query_backend_resolved.options(query, selectinload(model.roles)) + except Exception: + pass + result = session.scalar_one_or_none(query) + user = await result if hasattr(result, "__await__") else result if not user: return None if not user.verify_password(password): return None return user - async def get_user(self, user_id: int | str, session: Any) -> AdminUserProtocol | None: - from sqlalchemy import select - from sqlalchemy.orm import selectinload - - session = as_session_backend(session) + async def get_user( + self, + user_id: int | str, + session: Any, + query_adapter: Any | None = None, + query_backend: Any | None = None, + **kwargs: Any, + ) -> AdminUserProtocol | None: + qb = query_adapter if query_adapter is not None else query_backend + if qb is None: + qb = kwargs.get("query_adapter") or kwargs.get("query_backend") + query_backend_resolved = self._resolve_query_backend(qb) + session = self._resolve_session(session) model = self._get_model() - query = select(model).where(model.id == user_id, model.is_active.is_(True)) + query = query_backend_resolved.select(model) + is_active_col = getattr(model, "is_active", None) + id_col = getattr(model, "id", None) + if id_col is None: + return None + if is_active_col is not None: + query = query_backend_resolved.where( + query, + id_col == user_id, + is_active_col == True, # noqa: E712 + ) + else: + query = query_backend_resolved.where(query, id_col == user_id) # Eagerly load roles if the model has a roles relationship if hasattr(model, "roles"): - query = query.options(selectinload(model.roles)) + try: + if query_backend_resolved.__class__.__name__ == "SqlAlchemyQueryAdapter": + from sqlalchemy.orm import selectinload + + query = query_backend_resolved.options(query, selectinload(model.roles)) + except Exception: + pass - return await session.scalar_one_or_none(query) + result = session.scalar_one_or_none(query) + return await result if hasattr(result, "__await__") else result async def on_logout(self, user_id: int | str | None = None) -> None: """No-op for built-in backend.""" diff --git a/fastapi_admin_kit/auth/csrf.py b/fastapi_admin_kit/auth/csrf.py index 16cce03..48ab23f 100644 --- a/fastapi_admin_kit/auth/csrf.py +++ b/fastapi_admin_kit/auth/csrf.py @@ -34,11 +34,16 @@ def _get_secret_key(request: Request) -> str | None: def generate_csrf_token(secret_key: str) -> str: - """Generate a signed CSRF token: ``timestamp.random_hex.signature``.""" + """Generate a signed CSRF token: ``timestamp.random_hex.signature``. + + The HMAC-SHA256 signature is kept at FULL length (64 hex chars) — the + previous 32-char truncation halved the signature strength for no gain + (S18). + """ random_bytes = os.urandom(16) timestamp = str(int(time.time())) payload = f"{timestamp}.{random_bytes.hex()}" - signature = hmac.new(secret_key.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32] + signature = hmac.new(secret_key.encode(), payload.encode(), hashlib.sha256).hexdigest() return f"{payload}.{signature}" @@ -49,7 +54,7 @@ def _verify_csrf_token(secret_key: str, token: str) -> bool: return False timestamp_str, random_hex, provided_sig = parts payload = f"{timestamp_str}.{random_hex}" - expected_sig = hmac.new(secret_key.encode(), payload.encode(), hashlib.sha256).hexdigest()[:32] + expected_sig = hmac.new(secret_key.encode(), payload.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(provided_sig, expected_sig): return False try: @@ -62,7 +67,16 @@ def _verify_csrf_token(secret_key: str, token: str) -> bool: def set_csrf_cookie(response: Response, secret_key: str, secure: bool = False) -> str: - """Generate and set the CSRF cookie on a response. Returns the token.""" + """Generate and set the CSRF cookie on a response. Returns the token. + + .. note:: + + The cookie is intentionally ``httponly=False`` — the double-submit + pattern requires JavaScript to read it for ``X-CSRF-Token`` headers. + Because the token is readable from JS, an XSS can exfiltrate it; + deploy a restrictive Content-Security-Policy (no inline scripts from + untrusted sources) alongside this admin to keep that window closed. + """ token = generate_csrf_token(secret_key) response.set_cookie( key=CSRF_COOKIE_NAME, @@ -146,6 +160,41 @@ async def require_csrf_token(request: Request) -> None: # --------------------------------------------------------------------------- +def _extract_multipart_field(body: bytes, content_type: str, field_name: str) -> str | None: + """Extract a single field's value from a raw multipart/form-data body. + + Multipart bodies are NOT urlencoded — ``parse_qs`` on them never finds + the CSRF field (S18). We split on the boundary and read the part whose + ``Content-Disposition`` names *field_name*, without consuming the + request stream the route still needs. + """ + boundary = None + for piece in content_type.split(";"): + piece = piece.strip() + if piece.lower().startswith("boundary="): + boundary = piece[len("boundary=") :].strip('"').encode() + break + if not boundary: + return None + + delimiter = b"--" + boundary + for part in body.split(delimiter): + if part in (b"", b"--", b"--\r\n") or part.startswith(b"--"): + continue + header_end = part.find(b"\r\n\r\n") + if header_end == -1: + continue + headers = part[:header_end] + name_marker = f'name="{field_name}"'.encode() + if name_marker not in headers: + continue + value = part[header_end + 4 :] + if value.endswith(b"\r\n"): + value = value[:-2] + return value.decode("utf-8", errors="replace") + return None + + class CSRFMiddleware(BaseHTTPMiddleware): """Middleware that: 1. Generates a CSRF token per request and stores it in ``request.state.csrf_token`` @@ -173,19 +222,23 @@ async def dispatch(self, request: Request, call_next) -> Response: # On state-changing requests, extract CSRF token from form body if request.method in ("POST", "PUT", "PATCH", "DELETE"): content_type = request.headers.get("content-type", "") - is_form = ( - "application/x-www-form-urlencoded" in content_type - or "multipart/form-data" in content_type - ) - if is_form: + is_urlencoded = "application/x-www-form-urlencoded" in content_type + is_multipart = "multipart/form-data" in content_type + if is_urlencoded or is_multipart: try: body = await request.body() - from urllib.parse import parse_qs - - form_data = parse_qs(body.decode("utf-8", errors="replace")) - csrf_values = form_data.get(CSRF_FORM_FIELD) - if csrf_values: - request.state._csrf_token = csrf_values[0] + token: str | None = None + if is_multipart: + token = _extract_multipart_field(body, content_type, CSRF_FORM_FIELD) + else: + from urllib.parse import parse_qs + + form_data = parse_qs(body.decode("utf-8", errors="replace")) + csrf_values = form_data.get(CSRF_FORM_FIELD) + if csrf_values: + token = csrf_values[0] + if token: + request.state._csrf_token = token except Exception: pass # Let the dependency handle missing token diff --git a/fastapi_admin_kit/auth/dependencies.py b/fastapi_admin_kit/auth/dependencies.py index c0ac1f7..13871c2 100644 --- a/fastapi_admin_kit/auth/dependencies.py +++ b/fastapi_admin_kit/auth/dependencies.py @@ -68,6 +68,11 @@ async def get_current_admin_user( if user_id is None: raise HTTPException(status_code=401, detail="Invalid session payload.") + # S18: every session token must carry an ``iat`` — without it the + # password-change invalidation below would silently not apply. + if session_payload.get("iat") is None: + raise HTTPException(status_code=401, detail="Invalid session payload.") + # request.state.admin_user is populated as a side effect. from fastapi_admin_kit.auth.identity import resolve_user diff --git a/fastapi_admin_kit/auth/identity.py b/fastapi_admin_kit/auth/identity.py index 4014f3b..aae562b 100644 --- a/fastapi_admin_kit/auth/identity.py +++ b/fastapi_admin_kit/auth/identity.py @@ -95,7 +95,12 @@ async def resolve_user(request: Request, user_id: int | str | None) -> AdminUser # it would roll back the current request's session, destroying data such as # newly inserted objects in API create/update flows. The pending-rollback # state is better handled by SessionMiddleware or explicit error handling. - user = await auth_backend.get_user(user_id, session) + # Pass the QueryBackend so BuiltinAuthBackend stays ORM-agnostic. + query_adapter = getattr(request.app.state, "admin_query_adapter", None) + try: + user = await auth_backend.get_user(user_id, session, query_adapter=query_adapter) + except TypeError: + user = await auth_backend.get_user(user_id, session) if user is None or not getattr(user, "is_active", False): return None @@ -188,4 +193,15 @@ async def get_current_user_from_bearer( user_id: int | str = int(sub) # type: ignore[assignment] except (TypeError, ValueError): return None - return await resolve_user(request, user_id) + + user = await resolve_user(request, user_id) + if user is None: + return None + + # A password change after the token was minted kills it immediately. + from fastapi_admin_kit.api.auth import token_predates_password_change + + if token_predates_password_change(payload, user): + return None + + return user diff --git a/fastapi_admin_kit/auth/mixins.py b/fastapi_admin_kit/auth/mixins.py index 6130b90..8836ec7 100644 --- a/fastapi_admin_kit/auth/mixins.py +++ b/fastapi_admin_kit/auth/mixins.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING, ClassVar -from sqlalchemy import Boolean, Column, String +from sqlalchemy import Boolean, Column, DateTime, String from fastapi_admin_kit.backends import as_session_backend @@ -15,16 +15,16 @@ class AuthModelMixin: """Mixin for custom user models to work with admin's built-in RBAC. - Provides: hashed_password, is_active, is_superuser columns, + Provides: password, is_active, is_superuser, last_login columns, role_ids property, verify_password() and hash_password() methods. Usage:: - from fastapi_admin_kit.auth.mixins import AutoModelMixin + from fastapi_admin_kit.auth.mixins import AuthModelMixin from fastapi_admin_kit.auth.models import admin_user_roles, Role from sqlalchemy.orm import relationship - class MyUser(AutoModelMixin, Base): + class MyUser(AuthModelMixin, Base): __tablename__ = "my_users" id = Column(Integer, primary_key=True) @@ -37,9 +37,10 @@ class MyUser(AutoModelMixin, Base): ) The mixin provides: - - ``hashed_password`` column (String 255) + - ``password`` column (String 255) — stores the hashed password - ``is_active`` column (Boolean, default True) - ``is_superuser`` column (Boolean, default False) + - ``last_login`` column (DateTime with timezone, nullable) - ``role_ids`` property → ``list[int]`` (reads from ``self.roles``) - ``verify_password(password)`` → bool - ``hash_password(password)`` → str (classmethod) @@ -49,9 +50,10 @@ class MyUser(AutoModelMixin, Base): _hasher: ClassVar[type | None] = None - hashed_password = Column(String(255), nullable=False) + password = Column(String(255)) is_active = Column(Boolean, default=True) is_superuser = Column(Boolean, default=False) + last_login = Column(DateTime(timezone=True), nullable=True) @property def role_ids(self) -> list[int]: @@ -65,7 +67,7 @@ def verify_password(self, password: str) -> bool: """Check if plaintext password matches the stored hash.""" from fastapi_admin_kit.auth.password import password_manager - return password_manager.verify(password, self.hashed_password) + return password_manager.verify(password, self.password) @classmethod def hash_password(cls, password: str) -> str: diff --git a/fastapi_admin_kit/auth/password.py b/fastapi_admin_kit/auth/password.py index c019a17..1e916e4 100644 --- a/fastapi_admin_kit/auth/password.py +++ b/fastapi_admin_kit/auth/password.py @@ -40,13 +40,16 @@ def needs_rehash(cls, hashed: str) -> bool: Returns True if the stored hash was created with fewer rounds than the current default, or if the hash cannot be parsed. + + bcrypt format: ``$2b$$<22-char salt><31-char hash>`` — the + cost factor lives in ``parts[2]`` (S13: previously read from + ``parts[3]``, the hash body, so rehashing triggered always/never). """ try: - # bcrypt hashes encode rounds as: $2b$$... parts = hashed.split("$") - if len(parts) < 3: + if len(parts) < 4 or not parts[2].isdigit(): return True - actual_rounds = int(parts[3]) if parts[3].isdigit() else 0 + actual_rounds = int(parts[2]) return actual_rounds < cls._default_rounds except (IndexError, ValueError): return True diff --git a/fastapi_admin_kit/auth/proxy.py b/fastapi_admin_kit/auth/proxy.py new file mode 100644 index 0000000..c240a9b --- /dev/null +++ b/fastapi_admin_kit/auth/proxy.py @@ -0,0 +1,98 @@ +"""Client-IP resolution behind reverse proxies. + +By default only the socket peer address is trusted. ``X-Forwarded-For`` is +honoured **only** when the immediate peer is itself a configured trusted +proxy (``AuthConfig(trusted_proxies=[...])``). Without that configuration the +header is attacker-controlled data and must be ignored — otherwise any client +can rotate its rate-limit bucket per request by sending a fresh XFF value. + +Entries may be single IPs (``"10.0.0.5"``) or CIDR networks +(``"10.0.0.0/8"``, ``"fd00::/8"``). +""" + +from __future__ import annotations + +import ipaddress +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from fastapi import Request + +UNKNOWN_IP = "unknown" + + +def parse_trusted_proxies(entries: Any) -> tuple[Any, ...]: + """Parse config entries into ``ipaddress`` objects (addresses or networks). + + Invalid entries are skipped rather than raising — a typo in a proxy list + must never take down request handling. + """ + parsed: list[Any] = [] + for entry in entries or []: + text = str(entry).strip() + if not text: + continue + try: + if "/" in text: + parsed.append(ipaddress.ip_network(text, strict=False)) + else: + parsed.append(ipaddress.ip_address(text)) + except ValueError: + continue + return tuple(parsed) + + +def get_trusted_proxies(request: Request) -> tuple[Any, ...]: + """Read ``trusted_proxies`` from app state. + + Supports both the wired dict form (``app.state.admin_config``) and a raw + ``AdminConfig`` object. + """ + state = getattr(getattr(request, "app", None), "state", None) + config = getattr(state, "admin_config", None) + if isinstance(config, dict): + entries = config.get("trusted_proxies") + else: + entries = getattr(getattr(config, "auth", None), "trusted_proxies", None) + return parse_trusted_proxies(entries) + + +def is_trusted(host: str, trusted: tuple[Any, ...]) -> bool: + """True when *host* is one of the trusted addresses / inside a network.""" + try: + addr = ipaddress.ip_address(str(host)) + except ValueError: + return False + for entry in trusted: + if isinstance(entry, ipaddress.IPv4Network | ipaddress.IPv6Network): + if addr in entry: + return True + elif addr == entry: + return True + return False + + +def get_client_ip(request: Request) -> str: + """Resolve the client IP for rate limiting and audit records. + + Resolution rules: + + 1. Default: the socket peer address (``request.client.host``). + 2. ``X-Forwarded-For`` is considered ONLY when the peer matches + ``trusted_proxies``. The chain is walked right-to-left, skipping + trusted hops; the first untrusted address wins (the client as seen by + the leftmost trusted proxy). + 3. When every entry in the chain is trusted (or nothing parseable + remains) fall back to the peer address. + """ + peer = request.client.host if request.client else UNKNOWN_IP + trusted = get_trusted_proxies(request) + forwarded = request.headers.get("x-forwarded-for") + if not forwarded or not trusted or not is_trusted(peer, trusted): + return peer + + for candidate in reversed([c.strip() for c in forwarded.split(",") if c.strip()]): + if is_trusted(candidate, trusted): + continue + return candidate + return peer diff --git a/fastapi_admin_kit/auth/ratelimit.py b/fastapi_admin_kit/auth/ratelimit.py index b633125..a3aa56d 100644 --- a/fastapi_admin_kit/auth/ratelimit.py +++ b/fastapi_admin_kit/auth/ratelimit.py @@ -1,35 +1,55 @@ -"""In-memory sliding window rate limiter — no external dependencies. +"""Sliding-window rate limiter — no external dependencies. .. warning:: - This rate limiter stores state in process memory. When running with - multiple workers (e.g., ``gunicorn -w N`` or multiple Kubernetes pods), - each worker has its own rate limiter state. An attacker can bypass rate - limiting by hitting different workers. + **MULTI-WORKER DEPLOYMENTS: the in-memory limiter does NOT work.** + State lives in this process's memory only. With ``gunicorn -w N``, + multiple uvicorn workers, or several Kubernetes replicas, every worker + has its own counters and an attacker gets ``N × max_attempts`` requests + simply by round-robining across workers. - For production deployments with multiple workers, consider: - - Using a Redis-backed rate limiter - - Using an API gateway rate limiter (e.g., Cloudflare, NGINX) - - Deploying behind a reverse proxy with its own rate limiting + For multi-worker production deployments you MUST either: - This implementation is suitable for single-worker deployments and - development environments. + - configure Redis (``REDIS_URL``) so the distributed + :class:`~fastapi_admin_kit.redis.RedisLoginRateGuard` is used, or + - terminate rate limiting at your gateway / reverse proxy. + + The in-memory implementation is suitable for single-process + deployments and development only. """ from __future__ import annotations +import asyncio import time from collections import defaultdict -from threading import Lock -from fastapi import HTTPException, Request +from fastapi import HTTPException + +from fastapi_admin_kit.auth.proxy import get_client_ip + +__all__ = [ + "RateLimiter", + "check_rate_limit", + "get_client_ip", + "_client_ip", # backward-compatible alias +] + +# Backward compatibility: code (and third-party integrations) previously +# imported ``_client_ip`` from this module. It now enforces trusted-proxy +# validation instead of blindly trusting X-Forwarded-For. +_client_ip = get_client_ip class RateLimiter: - """Sliding-window rate limiter. + """Async sliding-window rate limiter. Tracks request timestamps per key and rejects when the count exceeds - ``max_attempts`` within ``window_seconds``. + ``max_attempts`` within ``window_seconds``. Guarded by an + :class:`asyncio.Lock` so concurrent coroutines on one event loop can + never interleave read-modify-write cycles (the previous + ``threading.Lock`` version could block the loop without protecting + against interleaved awaits). """ def __init__( @@ -40,63 +60,51 @@ def __init__( self.max_attempts = max_attempts self.window_seconds = window_seconds self._attempts: dict[str, list[float]] = defaultdict(list) - self._lock = Lock() + self._lock = asyncio.Lock() - def _cleanup(self, key: str, now: float) -> None: + async def _cleanup(self, key: str, now: float) -> None: """Remove expired entries for *key*.""" cutoff = now - self.window_seconds attempts = self._attempts[key] self._attempts[key] = [t for t in attempts if t > cutoff] - def is_rate_limited(self, key: str) -> bool: + async def is_rate_limited(self, key: str) -> bool: """Return True if *key* has exceeded the allowed attempts.""" now = time.monotonic() - with self._lock: - self._cleanup(key, now) - if len(self._attempts[key]) >= self.max_attempts: - return True - return False + async with self._lock: + await self._cleanup(key, now) + return len(self._attempts[key]) >= self.max_attempts - def record_attempt(self, key: str) -> None: + async def record_attempt(self, key: str) -> None: """Record a request attempt for *key*.""" now = time.monotonic() - with self._lock: - self._cleanup(key, now) + async with self._lock: + await self._cleanup(key, now) self._attempts[key].append(now) - def reset(self, key: str) -> None: + async def reset(self, key: str) -> None: """Clear all attempts for *key* (e.g. on successful login).""" - with self._lock: + async with self._lock: self._attempts.pop(key, None) - def remaining_seconds(self, key: str) -> int: + async def remaining_seconds(self, key: str) -> int: """Seconds until the oldest attempt in the window expires.""" now = time.monotonic() - with self._lock: - self._cleanup(key, now) + async with self._lock: + await self._cleanup(key, now) attempts = self._attempts[key] if not attempts: return 0 return max(0, int(self.window_seconds - (now - attempts[0])) + 1) -def _client_ip(request: Request) -> str: - """Extract client IP, respecting X-Forwarded-For.""" - forwarded = request.headers.get("x-forwarded-for") - if forwarded: - return forwarded.split(",")[0].strip() - if request.client: - return request.client.host - return "unknown" - - -def check_rate_limit( +async def check_rate_limit( limiter: RateLimiter, key: str, ) -> None: """Raise 429 if *key* is rate-limited.""" - if limiter.is_rate_limited(key): - retry = limiter.remaining_seconds(key) + if await limiter.is_rate_limited(key): + retry = await limiter.remaining_seconds(key) raise HTTPException( status_code=429, detail="Too many attempts. Please try again later.", diff --git a/fastapi_admin_kit/auth/session.py b/fastapi_admin_kit/auth/session.py index ad9d409..3fc8236 100644 --- a/fastapi_admin_kit/auth/session.py +++ b/fastapi_admin_kit/auth/session.py @@ -2,8 +2,12 @@ from __future__ import annotations +import datetime as _dt +import json as _json import time +import uuid as _uuid from abc import ABC, abstractmethod +from decimal import Decimal from typing import Any from itsdangerous import ( @@ -13,6 +17,38 @@ ) +def _json_default(obj: Any) -> Any: + """Coerce non-stdlib types into JSON-safe values when serializing + session payloads. + + Custom ``auth_model`` implementations may use ``UUID``, ``datetime``, + ``Decimal``, or other non-JSON-native types for the user primary key + or session metadata. Without this handler, ``json.dumps`` raises + ``TypeError: Object of type UUID is not JSON serializable`` at the + first ``session_backend.encode({"user_id": user.id, ...})`` call. + """ + if isinstance(obj, _uuid.UUID): + return str(obj) + if isinstance(obj, _dt.datetime | _dt.date | _dt.time): + return obj.isoformat() + if isinstance(obj, _dt.timedelta): + return obj.total_seconds() + if isinstance(obj, Decimal): + return str(obj) + if isinstance(obj, set | frozenset): + return list(obj) + if isinstance(obj, bytes): + return obj.decode("utf-8", errors="replace") + # Last resort: stringify so we never crash the login path. The receiving + # side is responsible for knowing what shape to expect. + return str(obj) + + +# Sanity-check at import time: ensure ``json`` module is what itsdangerous +# will resolve (it picks ``json`` when no ``serializer=`` is given). +_ = _json + + class SessionBackend(ABC): """Abstract session backend — encode/decode session payloads.""" @@ -46,7 +82,29 @@ def __init__( secure: bool = False, ) -> None: self._secret_key = secret_key - self._serializer = URLSafeTimedSerializer(secret_key, salt="admin-session") + # Custom JSON module lets us encode UUIDs, datetimes, Decimals, etc. + # that may appear in a session payload when a project supplies a + # custom ``auth_model`` whose ``id`` is a UUID (e.g. SQLModel / + # SQLAlchemy ``Uuid`` PK). Without this, ``json.dumps`` raises + # ``TypeError: Object of type UUID is not JSON serializable`` the + # first time a user with a UUID PK tries to log in. + # + # ``itsdangerous`` forwards ``**serializer_kwargs`` to ``dumps``, so + # passing ``default=_json_default`` is the supported way to register + # a custom encoder without subclassing. + self._serializer = URLSafeTimedSerializer( + secret_key, + salt="admin-session", + serializer_kwargs={"default": _json_default}, + ) + # 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", + serializer_kwargs={"default": _json_default}, + ) + self._mfa_ttl = 300 self._session_ttl = session_ttl self.cookie_name = cookie_name self.secure = secure @@ -68,13 +126,21 @@ def encode(self, payload: dict[str, Any]) -> str: return self._serializer.dumps(payload) def decode(self, token: str | None) -> dict[str, Any] | None: - """Verify *token* and return the decoded payload, or ``None``.""" + """Verify *token* and return the decoded payload dict, or ``None``. + + Tokens without an ``iat`` claim are rejected outright (S18): the + ``iat`` timestamp powers the password-change invalidation check, and + a token minted without it would silently bypass that revocation. + """ if not token: return None try: - return self._serializer.loads(token, max_age=self._session_ttl) + payload = self._serializer.loads(token, max_age=self._session_ttl) except (BadSignature, SignatureExpired, ValueError): return None + if not isinstance(payload, dict) or "iat" not in payload: + return None + return payload def should_secure(self, request: Any) -> bool: """Return True only when the configured secure flag is set AND the @@ -88,6 +154,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..bcfa93e 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: @@ -69,18 +70,89 @@ def generate_backup_codes(count: int = 10) -> list[str]: def hash_backup_code(code: str) -> str: - """Hash a backup code for storage.""" - return hashlib.sha256(code.encode()).hexdigest() + """Hash a backup code for storage with bcrypt (S12). + + Backup codes were previously stored as unsalted SHA256 — fast to + brute-force if the DB leaks. New codes are bcrypt-hashed like + passwords; :func:`verify_backup_code` still accepts legacy SHA256 + hashes so existing records keep working until regenerated. + """ + import bcrypt + + return bcrypt.hashpw(code.encode(), bcrypt.gensalt()).decode() + + +def _is_bcrypt_hash(value: str) -> bool: + return value.startswith(("$2a$", "$2b$", "$2y$")) def verify_backup_code(code: str, hashed_codes: list[str]) -> bool: """Verify a backup code against a list of hashed codes. - Returns True if the code matches and removes it from the list (in-place). + Accepts both modern bcrypt hashes and legacy unsalted-SHA256 hex + hashes (migration path). Returns True if the code matches and removes + it from the list (in-place) so it cannot be reused. """ - code_hash = hash_backup_code(code) + import bcrypt + for i, h in enumerate(hashed_codes): - if hmac.compare_digest(code_hash, h): - hashed_codes.pop(i) - return True + if _is_bcrypt_hash(h): + try: + if bcrypt.checkpw(code.encode(), h.encode()): + hashed_codes.pop(i) + return True + except ValueError: + continue + else: + # Legacy unsalted SHA256 (pre-S12 records) + legacy_hash = hashlib.sha256(code.encode()).hexdigest() + if hmac.compare_digest(legacy_hash, h): + 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 e2cae8f..522848e 100644 --- a/fastapi_admin_kit/auth/views.py +++ b/fastapi_admin_kit/auth/views.py @@ -2,6 +2,7 @@ from __future__ import annotations +import secrets from datetime import UTC, datetime from typing import Any from urllib.parse import urlparse @@ -19,17 +20,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.proxy import get_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,20 +101,63 @@ 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) + client_ip = get_client_ip(request) + 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) + # Use the multi-ORM seam: pass the QueryBackend so BuiltinAuthBackend + # builds queries via backend.query instead of importing sqlalchemy. + query_adapter = getattr(request.app.state, "admin_query_adapter", None) + try: + user = await auth_backend.authenticate( + username, + password, + session, + login_field=login_field, + query_adapter=query_adapter, + ) + print("login user: ", user) + except TypeError: + print("login user error: ", user) + # Custom backends that don't accept query_adapter + user = await auth_backend.authenticate(username, password, session, login_field=login_field) if user is not None: - _login_rate_limiter.reset(client_ip) - user.last_login = datetime.now(UTC) + await _guard.reset(client_ip) + now_utc = datetime.now(UTC) + user_fields = getattr(user, "model_fields", None) or getattr(user, "__fields__", None) or {} + if "last_login" in user_fields: + user.last_login = now_utc + elif "last_login_at" in user_fields: + user.last_login_at = 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, @@ -130,7 +169,10 @@ async def login_post( await session.flush() session_backend: SessionBackend = request.app.state.admin_session_backend - session_data = {"user_id": user.id} + # Random session id (S18): guarantees a fresh cookie value on every + # login — two logins in the same second must never mint identical + # session tokens (itsdangerous timestamps alone are second-granular). + session_data = {"user_id": user.id, "sid": secrets.token_urlsafe(32)} token = session_backend.encode(session_data) if next and _is_safe_url(next): @@ -153,13 +195,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 +217,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/backends/protocols.py b/fastapi_admin_kit/backends/protocols.py index 2486a89..f4ccb3f 100644 --- a/fastapi_admin_kit/backends/protocols.py +++ b/fastapi_admin_kit/backends/protocols.py @@ -262,7 +262,7 @@ def seed_roles( """ ... - def materialize(self, schema: Any, base: Any | None = None) -> type: + def materialize(self, schema: Any, base: Any | None = None, schemas: Any | None = None) -> type: """Convert a :class:`Schema` into a native model class. Returns a model understood by the backend's introspection adapter. diff --git a/fastapi_admin_kit/backends/sqlalchemy.py b/fastapi_admin_kit/backends/sqlalchemy.py index 0351d6d..2b94101 100644 --- a/fastapi_admin_kit/backends/sqlalchemy.py +++ b/fastapi_admin_kit/backends/sqlalchemy.py @@ -577,6 +577,13 @@ def create_tables(self, connection: Any, metadata: Any, tables: Any = None) -> A """ from sqlalchemy.ext.asyncio import AsyncEngine + if connection is None: + raise RuntimeError( + "create_tables called with connection=None — the Admin " + "instance has no engine. Pass `engine=` or `database_config=` " + "to Admin(), or set `use_alembic=True` to skip create_all." + ) + if isinstance(connection, AsyncEngine): async def _create() -> None: @@ -843,6 +850,7 @@ def materialize( self, schema: Any, base: Any | None = None, + schemas: Any | None = None, ) -> type: """Convert a :class:`Schema` into a SQLAlchemy model class. @@ -857,6 +865,11 @@ def materialize( describing the model structure. base: The SQLAlchemy declarative base class. If ``None``, falls back to the configured ``AdminDatabase.base`` or ``Base``. + schemas: Optional mapping of ``table_name -> Schema`` for every model + being materialized. Used to derive the type of a foreign-key column + from the referenced model's primary-key type (e.g. a ``user_id`` + column pointing at a User whose ``id`` is a UUID becomes a UUID + instead of an integer/string). Returns: A new SQLAlchemy model class with ``__tablename__`` and mapped columns. @@ -883,6 +896,7 @@ def materialize( ) from sqlalchemy.orm import relationship from sqlalchemy.sql import func + from sqlalchemy.types import Uuid from fastapi_admin_kit.schemas.schema import Schema as SchemaType @@ -905,22 +919,41 @@ def materialize( if table is not None and table.name == schema.table_name: return mapper.class_ - # Cross-dialect JSON type + # Cross-dialect JSON type: native JSON where supported (PostgreSQL, + # MySQL), TEXT with json.dumps/loads elsewhere (SQLite). from sqlalchemy import types class JSON(types.TypeDecorator): impl = Text cache_ok = True + def load_dialect_impl(self, dialect): + if dialect.name in ("postgresql", "mysql"): + return dialect.type_descriptor(types.JSON()) + return dialect.type_descriptor(Text()) + + def _uses_native_json(self, dialect) -> bool: + return dialect.name in ("postgresql", "mysql") + def process_bind_param(self, value, dialect): import json - return json.dumps(value) if value is not None else None + if value is None or isinstance(value, str): + return value + if self._uses_native_json(dialect): + return value + return json.dumps(value) def process_result_value(self, value, dialect): import json - return json.loads(value) if value is not None else None + if value is None: + return None + if self._uses_native_json(dialect): + return value + if isinstance(value, str): + return json.loads(value) + return value type_map: dict[str, type] = { "integer": Integer, @@ -931,8 +964,45 @@ def process_result_value(self, value, dialect): "float": Float, "numeric": Numeric, "json": JSON, + "uuid": Uuid, } + # Map a schema field to its SQLAlchemy type instance. Used both for the + # model columns and for deriving foreign-key column types from the + # referenced model's primary key. + def _schema_field_sa_type(fld: Any) -> Any: + if fld.type == "string" and fld.max_length: + return String(fld.max_length) + return type_map.get(fld.type, String) + + # Resolve the SQLAlchemy type of a relation target's primary key so a + # foreign-key column can mirror it (uuid vs int, etc.). *target* may be + # a table-name string or a materialized model class. + def _resolve_target_pk_type(target: Any) -> Any | None: + if not isinstance(target, str): + table = getattr(target, "__table__", None) + else: + table = None + has_meta = base is not None and hasattr(base, "metadata") + md = base.metadata.tables if has_meta else None + if md is not None and target in md: + table = md[target] + if table is None: + if schemas is None: + from fastapi_admin_kit.schemas.builtin import BUILTIN_SCHEMAS + reg = schemas if schemas is not None else BUILTIN_SCHEMAS + if reg and target in reg: + pk = reg[target].get_pk_field() + if pk is not None: + return _schema_field_sa_type(pk) + return None + if table is None: + return None + pk_cols = list(table.primary_key.columns) + if not pk_cols: + return None + return pk_cols[0].type + columns: list[Column] = [] existing_cols: dict[str, Any] = {} if base is not None and hasattr(base, "metadata"): @@ -945,6 +1015,21 @@ def process_result_value(self, value, dialect): rel.target: rel.name for rel in schema.relations if rel.type == "many_to_one" } + # Mirror a custom auth_model's primary-key type onto ``user_id`` columns + # even when the field is NOT declared as a many_to_one FK relation. The + # built-in schemas hardcode ``user_id`` as ``string``/``integer`` (the + # legacy default) so a custom User with a UUID PK would otherwise be + # materialized as the wrong SQL type. We only retype columns that + # reference the user table (or any of the well-known user-id columns); + # other FK columns are left to the many_to_one branch below. + user_pk_type: Any | None = None + if base is not None and hasattr(base, "metadata"): + users_table = base.metadata.tables.get("admin_users") + if users_table is not None: + pk_cols_real = list(users_table.primary_key.columns) + if pk_cols_real: + user_pk_type = pk_cols_real[0].type + for f in schema.fields: sa_type = type_map.get(f.type, String) @@ -961,6 +1046,8 @@ def process_result_value(self, value, dialect): kwargs["unique"] = True if f.max_length and sa_type is String: sa_type = String(f.max_length) + if f.name == "user_id" and user_pk_type is not None: + sa_type = user_pk_type if f.default is not None: kwargs["default"] = f.default if f.server_default is not None: @@ -1001,6 +1088,12 @@ def process_result_value(self, value, dialect): if fk_target: from sqlalchemy import ForeignKey + # Mirror the referenced model's primary-key type (uuid vs int, + # etc.) instead of blindly using the field's declared type. + resolved_pk_type = _resolve_target_pk_type(fk_target) + if resolved_pk_type is not None: + sa_type = resolved_pk_type + # Use string-based FK to allow target table to not exist yet columns.append( Column(f.name, sa_type, ForeignKey(f"{fk_target}.id", use_alter=True), **kwargs) @@ -1120,7 +1213,7 @@ def role_ids(self) -> list[int]: # Add verify_password method def verify_password(self, password: str) -> bool: - return password_manager.verify(password, self.hashed_password) + return password_manager.verify(password, self.password) model_class.verify_password = verify_password diff --git a/fastapi_admin_kit/cli/user.py b/fastapi_admin_kit/cli/user.py index 1b5d602..6aa49fb 100644 --- a/fastapi_admin_kit/cli/user.py +++ b/fastapi_admin_kit/cli/user.py @@ -4,6 +4,7 @@ import argparse import asyncio +import importlib import os import sys @@ -26,13 +27,55 @@ def _ensure_async_url(url: str) -> str: return url +def _hash_password(model: type, password: str) -> str: + """Hash a password using the model's hash_password or the default hasher.""" + hasher = getattr(model, "hash_password", None) + if hasher is None: + from fastapi_admin_kit.auth.password import password_manager + + return password_manager.hash(password) + try: + result = hasher(password) + if result is None: + from fastapi_admin_kit.auth.password import password_manager + + return password_manager.hash(password) + return result + except TypeError: + from fastapi_admin_kit.auth.password import password_manager + + return password_manager.hash(password) + + +_PASSWORD_COLUMNS = ("hashed_password", "password") +_ACTIVE_COLUMNS = ("is_active", "active", "enabled") +_SUPERUSER_COLUMNS = ("is_superuser", "is_admin", "is_staff", "superuser", "admin") + + +def _import_auth_model(import_path: str) -> type: + """Import an auth model class from a dotted module path.""" + module_path, _, class_name = import_path.rpartition(".") + if not module_path or not class_name: + print("Error: --auth-model must be a dotted path like 'myapp.models.MyUser'.") + sys.exit(1) + try: + cwd = os.getcwd() + if cwd not in sys.path: + sys.path.insert(0, cwd) + module = importlib.import_module(module_path) + model = getattr(module, class_name) + except (ImportError, AttributeError) as exc: + print(f"Error: Could not import auth_model '{import_path}': {exc}") + sys.exit(1) + return model + + async def _create_superuser(args: argparse.Namespace) -> None: """Create a superuser.""" from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import NullPool - from fastapi_admin_kit.auth.models import User from fastapi_admin_kit.models.base import Base database_url = _resolve_database_url(args.database_url) @@ -41,8 +84,14 @@ async def _create_superuser(args: argparse.Namespace) -> None: connect_args = {"timeout": 30} engine = create_async_engine(database_url, poolclass=NullPool, connect_args=connect_args) + UserModel = _import_auth_model(args.auth_model) if args.auth_model else None # noqa: N806 + if UserModel is None: + from fastapi_admin_kit.auth.models import User as UserModel + + target_metadata = getattr(UserModel, "metadata", Base.metadata) + async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(target_metadata.create_all) async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) @@ -50,28 +99,67 @@ async def _create_superuser(args: argparse.Namespace) -> None: with session.no_autoflush: from sqlalchemy import select - result = await session.execute(select(User).where(User.email == args.email)) + from fastapi_admin_kit.backends import SqlAlchemyIntrospectionAdapter + + result = await session.execute(select(UserModel).where(UserModel.email == args.email)) existing = result.scalar_one_or_none() if existing: print(f"Error: User with email '{args.email}' already exists.") await engine.dispose() sys.exit(1) - hashed_password = User.hash_password(args.password) - user = User( - email=args.email, - hashed_password=hashed_password, - full_name=args.name or "", - is_superuser=True, - is_active=True, - ) + hashed_password = _hash_password(UserModel, args.password) + introspection = SqlAlchemyIntrospectionAdapter() + columns, _ = introspection.inspect_model(UserModel) + column_keys = {c.name for c in columns} + + password_col = next((c for c in _PASSWORD_COLUMNS if c in column_keys), None) + if password_col is None: + print( + f"Error: auth model '{UserModel.__name__}' has no password column " + f"(expected one of: {', '.join(_PASSWORD_COLUMNS)})." + ) + await engine.dispose() + sys.exit(1) + + active_col = next((c for c in _ACTIVE_COLUMNS if c in column_keys), None) + superuser_col = next((c for c in _SUPERUSER_COLUMNS if c in column_keys), None) + if superuser_col is None: + print( + f"Warning: auth model '{UserModel.__name__}' has no superuser column " + f"(looked for: {', '.join(_SUPERUSER_COLUMNS)}). " + "The user will be created but will not be flagged as a superuser " + "and will not be able to access the admin." + ) + + user_kwargs: dict = { + "email": args.email, + password_col: hashed_password, + } + if active_col is not None: + user_kwargs[active_col] = True + if superuser_col is not None: + user_kwargs[superuser_col] = True + if args.name and "full_name" in column_keys: + user_kwargs["full_name"] = args.name + elif args.name and "name" in column_keys: + user_kwargs["name"] = args.name + + user = UserModel(**user_kwargs) session.add(user) await session.commit() await session.refresh(user) + if not getattr(user, password_col, None): + print(f"Error: {password_col} was not saved for '{user.email}'.") + print(" Check that your custom model's password column is not nullable") + print(" and that no SQLAlchemy events or custom __init__ are overriding it.") + await engine.dispose() + sys.exit(1) + print("Superuser created successfully!") print(f" Email: {user.email}") - print(f" Name: {user.full_name or '(none)'}") + # print(f" Name: {user.full_name or '(none)'}") print(f" ID: {user.id}") await engine.dispose() @@ -83,7 +171,6 @@ async def _list_users(args: argparse.Namespace) -> None: from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import NullPool - from fastapi_admin_kit.auth.models import User from fastapi_admin_kit.models.base import Base database_url = _resolve_database_url(args.database_url) @@ -92,15 +179,23 @@ async def _list_users(args: argparse.Namespace) -> None: connect_args = {"timeout": 30} engine = create_async_engine(database_url, poolclass=NullPool, connect_args=connect_args) + UserModel = _import_auth_model(args.auth_model) if args.auth_model else None # noqa: N806 + if UserModel is None: + from fastapi_admin_kit.auth.models import User as UserModel + + target_metadata = getattr(UserModel, "metadata", Base.metadata) + async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(target_metadata.create_all) async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) async with async_session() as session: from sqlalchemy import select - result = await session.execute(select(User)) + from fastapi_admin_kit.backends import SqlAlchemyIntrospectionAdapter + + result = await session.execute(select(UserModel)) users = result.scalars().all() if not users: @@ -108,13 +203,34 @@ async def _list_users(args: argparse.Namespace) -> None: await engine.dispose() return - print(f"{'ID':<6} {'Email':<30} {'Name':<20} {'Superuser':<10} {'Active':<8}") - print("-" * 74) + introspection = SqlAlchemyIntrospectionAdapter() + columns, _ = introspection.inspect_model(UserModel) + column_keys = {c.name for c in columns} + superuser_col = next((c for c in _SUPERUSER_COLUMNS if c in column_keys), None) + active_col = next((c for c in _ACTIVE_COLUMNS if c in column_keys), None) + name_col = ( + "full_name" + if "full_name" in column_keys + else ("name" if "name" in column_keys else None) + ) + + def _flag(val_col: str | None) -> str: + if val_col is None: + return "n/a" + v = getattr(user, val_col, None) + return "Yes" if v else "No" + + name_header = "Name" if name_col else "" + name_width = 20 if name_col else 0 + print( + f"{'ID':<6} {'Email':<30} {name_header:<{name_width}} {'Superuser':<10} {'Active':<8}" + ) + print("-" * (74 + name_width)) for user in users: + name_val = getattr(user, name_col, "") if name_col else "" print( - f"{user.id:<6} {user.email:<30} {(user.full_name or ''):<20} " - f"{'Yes' if user.is_superuser else 'No':<10} " - f"{'Yes' if user.is_active else 'No':<8}" + f"{user.id:<6} {user.email:<30} {str(name_val):<{name_width}} " + f"{_flag(superuser_col):<10} {_flag(active_col):<8}" ) await engine.dispose() @@ -126,7 +242,6 @@ async def _change_password(args: argparse.Namespace) -> None: from sqlalchemy.orm import sessionmaker from sqlalchemy.pool import NullPool - from fastapi_admin_kit.auth.models import User from fastapi_admin_kit.models.base import Base database_url = _resolve_database_url(args.database_url) @@ -135,8 +250,14 @@ async def _change_password(args: argparse.Namespace) -> None: connect_args = {"timeout": 30} engine = create_async_engine(database_url, poolclass=NullPool, connect_args=connect_args) + UserModel = _import_auth_model(args.auth_model) if args.auth_model else None # noqa: N806 + if UserModel is None: + from fastapi_admin_kit.auth.models import User as UserModel + + target_metadata = getattr(UserModel, "metadata", Base.metadata) + async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) + await conn.run_sync(target_metadata.create_all) async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) @@ -144,7 +265,9 @@ async def _change_password(args: argparse.Namespace) -> None: with session.no_autoflush: from sqlalchemy import select - result = await session.execute(select(User).where(User.email == args.email)) + from fastapi_admin_kit.backends import SqlAlchemyIntrospectionAdapter + + result = await session.execute(select(UserModel).where(UserModel.email == args.email)) user = result.scalar_one_or_none() if not user: @@ -152,7 +275,19 @@ async def _change_password(args: argparse.Namespace) -> None: await engine.dispose() sys.exit(1) - user.hashed_password = User.hash_password(args.password) + introspection = SqlAlchemyIntrospectionAdapter() + columns, _ = introspection.inspect_model(UserModel) + column_keys = {c.name for c in columns} + password_col = next((c for c in _PASSWORD_COLUMNS if c in column_keys), None) + if password_col is None: + print( + f"Error: auth model '{UserModel.__name__}' has no password column " + f"(expected one of: {', '.join(_PASSWORD_COLUMNS)})." + ) + await engine.dispose() + sys.exit(1) + + setattr(user, password_col, _hash_password(UserModel, args.password)) await session.commit() print(f"Password changed successfully for '{user.email}'!") @@ -175,6 +310,12 @@ def register_user_commands(subparsers) -> None: default=None, help="Database URL (or set DATABASE_URL env var)", ) + create_parser.add_argument( + "-a", + "--auth-model", + default=None, + help="Dotted path to custom auth model (e.g. 'myapp.models.MyUser')", + ) # users list_parser = subparsers.add_parser("users", help="List all admin users") @@ -184,6 +325,12 @@ def register_user_commands(subparsers) -> None: default=None, help="Database URL (or set DATABASE_URL env var)", ) + list_parser.add_argument( + "-a", + "--auth-model", + default=None, + help="Dotted path to custom auth model (e.g. 'myapp.models.MyUser')", + ) # changepassword pw_parser = subparsers.add_parser("changepassword", help="Change password for an existing user") @@ -195,6 +342,12 @@ def register_user_commands(subparsers) -> None: default=None, help="Database URL (or set DATABASE_URL env var)", ) + pw_parser.add_argument( + "-a", + "--auth-model", + default=None, + help="Dotted path to custom auth model (e.g. 'myapp.models.MyUser')", + ) def handle_user_command(args: argparse.Namespace) -> None: 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/auth.py b/fastapi_admin_kit/config/auth.py index 368b1be..f651e73 100644 --- a/fastapi_admin_kit/config/auth.py +++ b/fastapi_admin_kit/config/auth.py @@ -26,6 +26,10 @@ def __init__( password_require_digit: bool = True, password_require_special: bool = True, session_samesite: str = "strict", + access_token_ttl: int = 600, + api_token_middleware: bool = True, + api_token_strict: bool = False, + trusted_proxies: list[str] | None = None, ): self.auth_model = auth_model self.auth_backend = auth_backend @@ -40,6 +44,21 @@ def __init__( self.password_require_digit = password_require_digit self.password_require_special = password_require_special self.session_samesite = session_samesite + # Short-lived JWT API access tokens (see api/auth.py). Refresh + # tokens keep sessions alive; a stolen bearer token expires within + # this window. + self.access_token_ttl = access_token_ttl + # Pre-validate bearer tokens on /api/* routes in middleware and + # cache the decoded payload for the request. + self.api_token_middleware = api_token_middleware + # When True, /api/* routes (minus exempt paths) require a bearer + # token even if the route itself has no auth dependency. + self.api_token_strict = api_token_strict + # Reverse proxies whose X-Forwarded-For header may be trusted when + # they are the DIRECT peer of the app (single IPs or CIDR networks). + # Empty (default) = never trust X-Forwarded-For; rate limiting and + # audit logs use the socket peer address. + self.trusted_proxies = [str(p) for p in trusted_proxies] if trusted_proxies else [] def get_hasher(self) -> Any: """Return the configured password hasher, or default BcryptHasher.""" @@ -64,7 +83,7 @@ def validate_auth_model(self) -> None: f"{', '.join(missing)}. Every auth model must have id and email." ) - # Required: is_active, is_superuser (can be provided by AutoModelMixin) + # Required: is_active, is_superuser (can be provided by AuthModelMixin) missing_flags = [] if not hasattr(model, "is_active"): missing_flags.append("is_active") @@ -73,7 +92,7 @@ def validate_auth_model(self) -> None: if missing_flags: raise ConfigError( f"auth_model {model.__name__!r} is missing: {', '.join(missing_flags)}. " - f"Use AutoModelMixin or add these columns to your model." + f"Use AuthModelMixin or add these columns to your model." ) # Required: roles or role_ids (for RBAC) @@ -81,19 +100,19 @@ def validate_auth_model(self) -> None: raise ConfigError( f"auth_model {model.__name__!r} has no 'roles' relationship or " f"'role_ids' property. RBAC requires role lookups. " - f"Use AutoModelMixin or define a roles relationship on your model." + f"Use AuthModelMixin or define a roles relationship on your model." ) # Check password-related attributes for authentication missing_auth = [] - if not hasattr(model, "hashed_password"): - missing_auth.append("hashed_password") + if not hasattr(model, "password"): + missing_auth.append("password") if not callable(getattr(model, "verify_password", None)): missing_auth.append("verify_password()") if missing_auth: raise ConfigError( f"auth_model {model.__name__!r} is missing password-related " f"attributes: {', '.join(missing_auth)}. " - f"Use AutoModelMixin or implement hashed_password (str) and " + f"Use AuthModelMixin or implement password (str) and " f"verify_password(password) -> bool." ) 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/export_import/base.py b/fastapi_admin_kit/export_import/base.py index dc19214..d6f1ccb 100644 --- a/fastapi_admin_kit/export_import/base.py +++ b/fastapi_admin_kit/export_import/base.py @@ -7,6 +7,21 @@ from abc import ABC, abstractmethod from typing import Any +FORMULA_PREFIXES = ("=", "+", "-", "@", "|", "%", "\t", "\r") +"""Leading characters spreadsheet apps may interpret as formulas (S14).""" + + +def sanitize_export_cell(value: Any) -> Any: + """Neutralise CSV/Excel formula injection in string cells. + + A DB value like ``=HYPERLINK(...)`` or ``=cmd|' /C calc'!A0`` executes + when the exported file is opened in Excel/LibreOffice. Prefixing a + single quote forces the cell to be treated as text. + """ + if isinstance(value, str) and value.startswith(FORMULA_PREFIXES): + return "'" + value + return value + class ExportBase(ABC): """Base class for all export implementations. @@ -52,11 +67,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. @@ -126,7 +147,7 @@ def export_rows(self, rows: list[Any], request: Any = None) -> io.BytesIO: for col in columns: value = self.get_value(obj, col) value = self.format_value(value, col) - row.append(value) + row.append(sanitize_export_cell(value)) writer.writerow(row) row_count += 1 @@ -159,15 +180,40 @@ def __init__(self, registered: Any) -> None: self.registered = registered self.admin = registered.admin + def allowed_import_fields(self) -> set[str]: + """Model columns an import may write (S18). + + Sensitive columns (``password``, tokens, ...) are always + excluded. On user-like models (anything exposing ``is_superuser``) + the privilege-granting fields are excluded too — a CSV upload must + never mint administrators or toggle account flags. + """ + from fastapi_admin_kit.inspection.types import ( + PRIVILEGED_ASSIGNMENT_FIELDS, + SENSITIVE_FIELDS, + ) + + allowed = {c.name for c in self.registered.columns if c.name not in SENSITIVE_FIELDS} + model = getattr(self.registered, "model", None) + if model is not None and hasattr(model, "is_superuser"): + allowed -= PRIVILEGED_ASSIGNMENT_FIELDS + return allowed + def get_field_map(self) -> dict[str, str]: """Get the mapping from file headers to model fields. Returns configured field_map or auto-generated mapping. + Restricted to non-sensitive, non-privileged model columns. """ if self.field_map: return self.field_map # Default: map header names to model field names (lowercase, underscored) - return {col.name.replace("_", " ").title(): col.name for col in self.registered.columns} + allowed = self.allowed_import_fields() + return { + col.name.replace("_", " ").title(): col.name + for col in self.registered.columns + if col.name in allowed + } def validate_row(self, row: dict[str, Any], index: int) -> tuple[bool, str | None]: """Validate a single row before import. @@ -291,6 +337,7 @@ async def import_data( field_map = self.get_field_map() unique_key = self.get_unique_key() model = self.registered.model + allowed_fields = self.allowed_import_fields() from sqlalchemy import inspect as sa_inspect @@ -303,10 +350,14 @@ async def import_data( for i, row in enumerate(rows): try: - # Map file headers to model field names + # Map file headers to model field names. Columns outside the + # import allow-list (sensitive / privilege-granting) are + # dropped, never written (S18). mapped_row = {} for file_header, value in row.items(): model_field = field_map.get(file_header, file_header) + if model_field not in allowed_fields: + continue mapped_row[model_field] = value # Validate row diff --git a/fastapi_admin_kit/export_import/csv.py b/fastapi_admin_kit/export_import/csv.py index 40dc5f8..2624a9e 100644 --- a/fastapi_admin_kit/export_import/csv.py +++ b/fastapi_admin_kit/export_import/csv.py @@ -6,7 +6,7 @@ import io from typing import Any -from fastapi_admin_kit.export_import.base import ExportBase, ImportBase +from fastapi_admin_kit.export_import.base import ExportBase, ImportBase, sanitize_export_cell class CSVExport(ExportBase): @@ -58,7 +58,7 @@ def export(self, queryset: Any, request: Any = None) -> io.BytesIO: for col in columns: value = self.get_value(obj, col) value = self.format_value(value, col) - row.append(value) + row.append(sanitize_export_cell(value)) writer.writerow(row) row_count += 1 diff --git a/fastapi_admin_kit/export_import/excel.py b/fastapi_admin_kit/export_import/excel.py index d68fd5c..b07874f 100644 --- a/fastapi_admin_kit/export_import/excel.py +++ b/fastapi_admin_kit/export_import/excel.py @@ -5,7 +5,7 @@ import io from typing import Any -from fastapi_admin_kit.export_import.base import ExportBase, ImportBase +from fastapi_admin_kit.export_import.base import ExportBase, ImportBase, sanitize_export_cell try: import openpyxl @@ -40,7 +40,8 @@ def format_value(self, value: Any, column: str) -> Any: Converts non-Excel-safe types (dicts, lists, etc.) to strings so openpyxl does not raise a ValueError. Native Excel types (int, float, - bool, datetime, str, None) are returned unchanged. + bool, datetime, str, None) are returned unchanged. String cells that + could be interpreted as formulas are neutralised (S14). """ import datetime @@ -50,10 +51,12 @@ def format_value(self, value: Any, column: str) -> Any: if isinstance( value, int | float | bool | str | datetime.datetime | datetime.date | datetime.time ): - return value + return sanitize_export_cell(value) # Related objects serialised as dicts → use their label/name/str if isinstance(value, dict): - return value.get("label") or value.get("name") or value.get("title") or str(value) + return sanitize_export_cell( + value.get("label") or value.get("name") or value.get("title") or str(value) + ) # Many-to-many / one-to-many lists if isinstance(value, list | tuple | set): parts = [] @@ -66,7 +69,7 @@ def format_value(self, value: Any, column: str) -> Any: parts.append(str(item)) return ", ".join(parts) # Fallback for any other unexpected type - return str(value) + return sanitize_export_cell(str(value)) def export(self, queryset: Any, request: Any = None) -> io.BytesIO: """Export queryset to Excel format. diff --git a/fastapi_admin_kit/inspection/types.py b/fastapi_admin_kit/inspection/types.py index ef447dc..3cdf6e7 100644 --- a/fastapi_admin_kit/inspection/types.py +++ b/fastapi_admin_kit/inspection/types.py @@ -5,6 +5,32 @@ from dataclasses import dataclass, field from typing import Any +SENSITIVE_FIELDS: frozenset[str] = frozenset( + { + "password", + "password_changed_at", + "secret", + "secret_key", + "token", + "refresh_token", + } +) +"""Column names that must never appear in serialized API output.""" + +PRIVILEGED_ASSIGNMENT_FIELDS: frozenset[str] = frozenset( + { + "is_superuser", + "is_active", + "roles", + } +) +"""User-model fields a non-superuser actor must never write (mass assignment). + +Distinct from :data:`SENSITIVE_FIELDS`: sensitive fields are secret +*values* kept out of serialization; privileged fields are privilege- +granting columns kept out of unprivileged *writes*. +""" + @dataclass class ColumnMeta: diff --git a/fastapi_admin_kit/migrations/models.py b/fastapi_admin_kit/migrations/models.py index d800db7..4ca2f63 100644 --- a/fastapi_admin_kit/migrations/models.py +++ b/fastapi_admin_kit/migrations/models.py @@ -22,6 +22,7 @@ AI_MESSAGE_SCHEMA, AI_USAGE_LOG_SCHEMA, AUDIT_LOG_SCHEMA, + BUILTIN_SCHEMAS, LOGIN_ATTEMPT_SCHEMA, NOTIFICATION_LOG_SCHEMA, NOTIFICATION_PREFERENCE_SCHEMA, @@ -75,31 +76,33 @@ # Auth models - order matters for foreign key resolution # Materialize child tables first so parent relationships can find FK columns -UserPermission = _backend.materialize(USER_PERMISSION_SCHEMA, base=Base) -RefreshToken = _backend.materialize(REFRESH_TOKEN_SCHEMA, base=Base) -UserTOTP = _backend.materialize(USER_TOTP_SCHEMA, base=Base) +UserPermission = _backend.materialize(USER_PERMISSION_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) +RefreshToken = _backend.materialize(REFRESH_TOKEN_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) +UserTOTP = _backend.materialize(USER_TOTP_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) # Then parent tables with relationships to children # Order matters for many-to-many back_populates relationships # Role must be materialized before User so the reverse relationship is available -Role = _backend.materialize(ROLE_SCHEMA, base=Base) -User = _backend.materialize(USER_SCHEMA, base=Base) -Permission = _backend.materialize(PERMISSION_SCHEMA, base=Base) +Role = _backend.materialize(ROLE_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) +User = _backend.materialize(USER_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) +Permission = _backend.materialize(PERMISSION_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) # Audit models -AuditLog = _backend.materialize(AUDIT_LOG_SCHEMA, base=Base) -LoginAttempt = _backend.materialize(LOGIN_ATTEMPT_SCHEMA, base=Base) +AuditLog = _backend.materialize(AUDIT_LOG_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) +LoginAttempt = _backend.materialize(LOGIN_ATTEMPT_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) # Notification models -Notification = _backend.materialize(NOTIFICATION_SCHEMA, base=Base) -NotificationPreference = _backend.materialize(NOTIFICATION_PREFERENCE_SCHEMA, base=Base) -NotificationLog = _backend.materialize(NOTIFICATION_LOG_SCHEMA, base=Base) +Notification = _backend.materialize(NOTIFICATION_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) +NotificationPreference = _backend.materialize( + NOTIFICATION_PREFERENCE_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS +) +NotificationLog = _backend.materialize(NOTIFICATION_LOG_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) # AI models -AIUsageLog = _backend.materialize(AI_USAGE_LOG_SCHEMA, base=Base) -AIConversation = _backend.materialize(AI_CONVERSATION_SCHEMA, base=Base) -AIMessage = _backend.materialize(AI_MESSAGE_SCHEMA, base=Base) -AIAttachment = _backend.materialize(AI_ATTACHMENT_SCHEMA, base=Base) +AIUsageLog = _backend.materialize(AI_USAGE_LOG_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) +AIConversation = _backend.materialize(AI_CONVERSATION_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) +AIMessage = _backend.materialize(AI_MESSAGE_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) +AIAttachment = _backend.materialize(AI_ATTACHMENT_SCHEMA, base=Base, schemas=BUILTIN_SCHEMAS) # Junction tables are now available via metadata admin_user_roles = Base.metadata.tables.get("admin_user_roles") diff --git a/fastapi_admin_kit/modeladmin.py b/fastapi_admin_kit/modeladmin.py index d1ec483..af81565 100644 --- a/fastapi_admin_kit/modeladmin.py +++ b/fastapi_admin_kit/modeladmin.py @@ -13,6 +13,31 @@ from fastapi_admin_kit.nav import NavItemConfig +def sanitize_ordering( + model: Any, + order: list[str], + extra_allowed: Any = (), +) -> list[str]: + """Restrict ordering terms to real model columns / relationships (S18). + + ``?ordering=`` is client-controlled; without an allow-list it could name + arbitrary model attributes (``metadata``, ``__table__``, ...) producing + 500s or odd SQL. Terms whose name (minus a leading ``-``) is not a + mapped column or relationship are dropped. + """ + if not order: + return [] + try: + from sqlalchemy import inspect as sa_inspect + + mapper = sa_inspect(model) + except Exception: # noqa: BLE001 — non-mapped model: keep admin defaults + return list(order) + allowed = {c.key for c in mapper.column_attrs} | {r.key for r in mapper.relationships} + allowed.update(extra_allowed) + return [term for term in order if term.lstrip("-") in allowed] + + class ModelAdmin: """Base class for model admin configuration. @@ -31,7 +56,11 @@ class ModelAdmin: list_filter_horizontal: bool = False @staticmethod - def get_ordering(request_params: dict, admin_ordering: list[str] | None) -> list[str]: + def get_ordering( + request_params: dict, + admin_ordering: list[str] | None, + model: Any = None, + ) -> list[str]: """Resolve ordering configuration based on request params and admin settings. Priority (highest to lowest): @@ -39,14 +68,22 @@ def get_ordering(request_params: dict, admin_ordering: list[str] | None) -> list 2. Admin class ordering (from ModelAdmin.ordering) 3. Empty list (no default ordering applied) - This prevents unwanted default sorting when ordering is not explicitly configured. + This prevents unwanted default sorting when ordering is not explicitly + configured. When *model* is given, the result is restricted to real + model columns/relationships via :func:`sanitize_ordering` — the query + parameter is client-controlled and must not reach arbitrary + ``getattr(model, ...)`` calls. """ query_ordering = request_params.get("ordering", "") if query_ordering: - return [query_ordering] + order = [query_ordering] elif admin_ordering: - return admin_ordering - return [] + order = admin_ordering + else: + return [] + if model is not None: + order = sanitize_ordering(model, order) + return order # Inline editing config inline_edit: bool = False @@ -76,6 +113,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..b0b2e56 --- /dev/null +++ b/fastapi_admin_kit/redis.py @@ -0,0 +1,325 @@ +"""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. +- :func:`resolve_rate_guard` is the generic form used for any rate-limit + purpose (login, API token, refresh, ...) with a per-purpose storage slot. + +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: + await check_rate_limit(self._limiter, key) + + async def is_rate_limited(self, key: str) -> bool: + return await self._limiter.is_rate_limited(key) + + async def record_failure(self, key: str) -> None: + await self._limiter.record_attempt(key) + + async def reset(self, key: str) -> None: + await self._limiter.reset(key) + + async def remaining_seconds(self, key: str) -> int: + return await 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 + + +# Per-process fallback limiters used when no Admin instance is mounted. +# See the auth.ratelimit module docstring for multi-worker implications. +_fallback_limiters: dict[str, RateLimiter] = {} + + +async def resolve_rate_guard( + request: Request, + *, + limit: int, + window: int, + slot: str, +) -> LoginRateGuard: + """Resolve a rate-limit guard for *request*. + + Uses the distributed Redis guard when Redis is enabled; otherwise falls + back to the in-memory limiter. In-memory state is stored on the admin + instance under *slot* (one limiter per purpose — login, API token, + refresh, ...). When no admin instance is mounted, a module-level + fallback keeps limiting functional for the process lifetime. + + .. warning:: + + The in-memory fallback is per-process. See the ``auth.ratelimit`` + module docstring for the multi-worker implications. + """ + admin = getattr(request.app.state, "admin", None) + redis_active = bool(getattr(admin, "redis_enabled", False)) if admin else redis_enabled() + if redis_active: + from redis_fastapi import get_rate_limit_backend + + backend = await get_rate_limit_backend(request) + return RedisLoginRateGuard(backend, limit=limit, window=window) + + limiter = getattr(admin, slot, None) if admin is not None else None + if limiter is None: + limiter = _fallback_limiters.get(slot) + if limiter is None: + limiter = RateLimiter(max_attempts=limit, window_seconds=window) + if admin is not None: + setattr(admin, slot, limiter) + else: + _fallback_limiters[slot] = limiter + return InMemoryLoginRateGuard(limiter) + + +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) + 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) + return await resolve_rate_guard( + request, + limit=login_limit, + window=login_window, + slot="_login_rate_limiter", + ) diff --git a/fastapi_admin_kit/router.py b/fastapi_admin_kit/router.py index b811e5a..e86024a 100644 --- a/fastapi_admin_kit/router.py +++ b/fastapi_admin_kit/router.py @@ -2,10 +2,15 @@ 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 -from fastapi_admin_kit.auth.dependencies import require_permission +from fastapi_admin_kit.auth.dependencies import ( + get_permission_checker, + require_permission, +) from fastapi_admin_kit.db import get_db_session from fastapi_admin_kit.notifications.dispatcher import dispatch_model_change from fastapi_admin_kit.registry import RegisteredModel @@ -55,19 +60,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 +98,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( @@ -134,8 +164,11 @@ async def search_view( error_msg = str(exc) or "An unexpected error occurred." if error_msg: + # Proper status codes (S16): 401 unauthenticated / 403 forbidden — + # a 200 body with {"error": ...} hid failures from API clients. + status = 401 if current_user is None else 403 return JSONResponse( - status_code=200, + status_code=status, content={"error": error_msg, "results": []}, ) @@ -158,15 +191,21 @@ async def search_view( async def export_data( request: Request, format: str = "csv", - _: None = Depends(require_permission(registered.table_name, "view")), + _: None = Depends(require_permission(registered.table_name, "export")), ): - """Export data in the specified format.""" + """Export data in the specified format. + + Single gate (S16): the ``export`` permission — previously the outer + dependency required only ``view`` while the in-handler check + required ``export``, so the two gates disagreed. + """ from fastapi.responses import StreamingResponse from fastapi_admin_kit.auth.identity import get_current_user_from_cookie from fastapi_admin_kit.db import get_db_session - # Check export permission + # Resolve the user for audit attribution (permission already + # enforced by the require_permission dependency above). current_user = await get_current_user_from_cookie(request) if current_user is None: raise HTTPException(status_code=401, detail="Not authenticated") @@ -175,11 +214,6 @@ async def export_data( user_email = getattr(current_user, "email", None) session = get_db_session(request) - from fastapi_admin_kit.auth.permissions import PermissionChecker - - checker = PermissionChecker(session=session, user=current_user) - if not await checker.has_permission(registered.table_name, "export"): - raise HTTPException(status_code=403, detail="Export permission denied") # Get export class export_class = admin.get_export_class(format) @@ -247,14 +281,19 @@ async def export_data( async def import_data( request: Request, format: str = "csv", - _: None = Depends(require_permission(registered.table_name, "create")), + _: None = Depends(require_permission(registered.table_name, "import")), _csrf: bool = Depends(require_csrf_token), ): - """Import data from uploaded file.""" + """Import data from uploaded file. + + Single gate (S16): the ``import`` permission — previously the outer + dependency required only ``create`` while the in-handler check + required ``import``. + """ from fastapi_admin_kit.auth.identity import get_current_user_from_cookie - from fastapi_admin_kit.db import get_db_session - # Check import permission + # Resolve the user for audit attribution (permission already + # enforced by the require_permission dependency above). current_user = await get_current_user_from_cookie(request) if current_user is None: raise HTTPException(status_code=401, detail="Not authenticated") @@ -262,13 +301,6 @@ async def import_data( user_id = getattr(current_user, "id", None) user_email = getattr(current_user, "email", None) - session = get_db_session(request) - from fastapi_admin_kit.auth.permissions import PermissionChecker - - checker = PermissionChecker(session=session, user=current_user) - if not await checker.has_permission(registered.table_name, "import"): - raise HTTPException(status_code=403, detail="Import permission denied") - # Get import class import_class = admin.get_import_class(format) if import_class is None: @@ -360,11 +392,26 @@ async def import_data( html = templates.TemplateResponse(request, "partials/list_table.html", ctx) return html + async def _require_create_or_edit( + checker: Any = Depends(get_permission_checker), + ) -> None: + """Field validation previews form errors for create/edit flows (S16): + requiring only ``view`` let read-only users probe validator logic.""" + table = registered.table_name + if not ( + await checker.has_permission(table, "create") + or await checker.has_permission(table, "edit") + ): + raise HTTPException( + status_code=403, + detail=f"You do not have permission to validate {table} fields.", + ) + @router.post("/validate-field", include_in_schema=False) async def validate_field_endpoint( request: Request, _csrf: bool = Depends(require_csrf_token), - _: None = Depends(require_permission(registered.table_name, "view")), + _: None = Depends(_require_create_or_edit), ): templates = request.app.state.admin_jinja_env form = await request.form() @@ -417,6 +464,12 @@ async def validate_field_endpoint( dependencies = list(opts.dependencies or []) if opts.permission: dependencies.append(Depends(require_permission(registered.table_name, opts.permission))) + elif not opts.allow_anonymous: + # Secure by default (S17): a custom endpoint without an explicit + # permission is NOT public — it requires authentication plus + # view permission on the model. Opt out with + # ``@endpoint(..., allow_anonymous=True)``. + dependencies.append(Depends(require_permission(registered.table_name, "view"))) router.add_api_route( opts.path, @@ -433,11 +486,43 @@ 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) + 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( @@ -696,6 +781,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) @@ -731,6 +817,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) @@ -757,31 +844,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) @@ -820,6 +882,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/schemas/builtin.py b/fastapi_admin_kit/schemas/builtin.py index a2ca7c5..e14cd8b 100644 --- a/fastapi_admin_kit/schemas/builtin.py +++ b/fastapi_admin_kit/schemas/builtin.py @@ -20,7 +20,7 @@ fields=[ Field("id", type="integer", primary_key=True, auto_increment=True), Field("email", type="string", max_length=255, unique=True, nullable=False), - Field("hashed_password", type="string", max_length=255, nullable=False), + Field("password", type="string", max_length=255, nullable=False), Field("full_name", type="string", max_length=255, nullable=True), Field("is_active", type="boolean", default=True), Field("is_superuser", type="boolean", default=False), @@ -36,24 +36,6 @@ through="admin_user_roles", back_populates="users", ), - Relation( - name="direct_permissions", - target="admin_user_permissions", - type="one_to_many", - back_populates="user", - ), - Relation( - name="refresh_tokens", - target="admin_refresh_tokens", - type="one_to_many", - back_populates="user", - ), - Relation( - name="totp", - target="admin_user_totp", - type="one_to_many", - back_populates="user", - ), ], ) @@ -135,7 +117,7 @@ verbose_name_plural="Audit Logs", fields=[ Field("id", type="integer", primary_key=True, auto_increment=True), - Field("user_id", type="integer", nullable=True, index=True), + Field("user_id", type="string", max_length=255, nullable=True, index=True), Field("user_email", type="string", max_length=255, nullable=True), Field("action", type="string", max_length=10, nullable=False), Field("model_name", type="string", max_length=255, nullable=False), @@ -187,16 +169,10 @@ verbose_name_plural="User Permissions", fields=[ Field("id", type="integer", primary_key=True), - Field("user_id", type="integer", nullable=False, index=True), + Field("user_id", type="string", max_length=255, nullable=False, index=True), Field("permission_id", type="integer", nullable=False, index=True), ], relations=[ - Relation( - name="user", - target="admin_users", - type="many_to_one", - back_populates="direct_permissions", - ), Relation( name="permission", target="admin_permissions", @@ -216,20 +192,13 @@ verbose_name_plural="Refresh Tokens", fields=[ Field("id", type="integer", primary_key=True), - Field("user_id", type="integer", nullable=False, index=True), + Field("user_id", type="string", max_length=255, nullable=False, index=True), Field("token_hash", type="string", max_length=64, nullable=False, index=True), Field("expires_at", type="datetime", nullable=False), Field("created_at", type="datetime", server_default="now()"), Field("revoked_at", type="datetime", nullable=True), ], - relations=[ - Relation( - name="user", - target="admin_users", - type="many_to_one", - back_populates="refresh_tokens", - ), - ], + relations=[], ) # --------------------------------------------------------------------------- @@ -242,20 +211,13 @@ verbose_name_plural="2FA Tokens", fields=[ Field("id", type="integer", primary_key=True), - Field("user_id", type="integer", unique=True, nullable=False, index=True), + Field("user_id", type="string", max_length=255, unique=True, nullable=False, index=True), Field("secret_key", type="string", max_length=255, nullable=False), Field("enabled", type="boolean", default=False), Field("backup_codes", type="text", nullable=True), Field("created_at", type="datetime", server_default="now()"), ], - relations=[ - Relation( - name="user", - target="admin_users", - type="many_to_one", - back_populates="totp", - ), - ], + relations=[], ) # --------------------------------------------------------------------------- @@ -466,6 +428,28 @@ ) +# Mapping of table name -> Schema for every built-in model. Used by the +# SQLAlchemy backend to derive foreign-key column types from the referenced +# model's primary key (e.g. a ``user_id`` column mirrors the User ``id`` type). +BUILTIN_SCHEMAS: dict[str, Schema] = { + USER_SCHEMA.table_name: USER_SCHEMA, + ROLE_SCHEMA.table_name: ROLE_SCHEMA, + PERMISSION_SCHEMA.table_name: PERMISSION_SCHEMA, + AUDIT_LOG_SCHEMA.table_name: AUDIT_LOG_SCHEMA, + LOGIN_ATTEMPT_SCHEMA.table_name: LOGIN_ATTEMPT_SCHEMA, + USER_PERMISSION_SCHEMA.table_name: USER_PERMISSION_SCHEMA, + REFRESH_TOKEN_SCHEMA.table_name: REFRESH_TOKEN_SCHEMA, + USER_TOTP_SCHEMA.table_name: USER_TOTP_SCHEMA, + NOTIFICATION_SCHEMA.table_name: NOTIFICATION_SCHEMA, + NOTIFICATION_PREFERENCE_SCHEMA.table_name: NOTIFICATION_PREFERENCE_SCHEMA, + NOTIFICATION_LOG_SCHEMA.table_name: NOTIFICATION_LOG_SCHEMA, + AI_USAGE_LOG_SCHEMA.table_name: AI_USAGE_LOG_SCHEMA, + AI_CONVERSATION_SCHEMA.table_name: AI_CONVERSATION_SCHEMA, + AI_MESSAGE_SCHEMA.table_name: AI_MESSAGE_SCHEMA, + AI_ATTACHMENT_SCHEMA.table_name: AI_ATTACHMENT_SCHEMA, +} + + __all__ = [ "USER_SCHEMA", "ROLE_SCHEMA", @@ -482,6 +466,7 @@ "AI_CONVERSATION_SCHEMA", "AI_MESSAGE_SCHEMA", "AI_ATTACHMENT_SCHEMA", + "BUILTIN_SCHEMAS", "AI_TABLE_NAMES", "NOTIFICATION_TABLE_NAMES", "INTERNAL_TABLE_NAMES", diff --git a/fastapi_admin_kit/storage/base.py b/fastapi_admin_kit/storage/base.py index d364be1..c318eae 100644 --- a/fastapi_admin_kit/storage/base.py +++ b/fastapi_admin_kit/storage/base.py @@ -8,6 +8,9 @@ from starlette.datastructures import UploadFile +DEFAULT_MAX_SIZE_MB = 10.0 +"""Default upload size limit applied when a backend/widget limit is ``None``.""" + class StorageBackend(ABC): """Abstract base class for file storage backends. diff --git a/fastapi_admin_kit/storage/local.py b/fastapi_admin_kit/storage/local.py index cc3ac07..ae17bf3 100644 --- a/fastapi_admin_kit/storage/local.py +++ b/fastapi_admin_kit/storage/local.py @@ -3,11 +3,14 @@ from __future__ import annotations import os +import re from pathlib import Path from starlette.datastructures import UploadFile -from fastapi_admin_kit.storage.base import StorageBackend +from fastapi_admin_kit.storage.base import DEFAULT_MAX_SIZE_MB, StorageBackend + +_DIRECTORY_RE = re.compile(r"^[A-Za-z0-9_-]+$") class LocalStorageBackend(StorageBackend): @@ -20,7 +23,9 @@ class LocalStorageBackend(StorageBackend): base_url: The URL prefix that maps to ``upload_dir`` (e.g. ``"/uploads"``). max_size_mb: - Maximum allowed file size in megabytes. ``None`` means no limit. + Maximum allowed file size in megabytes. ``None`` means the + :data:`~fastapi_admin_kit.storage.base.DEFAULT_MAX_SIZE_MB` limit + (10 MB) applies. """ def __init__( @@ -33,22 +38,58 @@ def __init__( self.base_url = base_url.rstrip("/") self.max_size_mb = max_size_mb + def _resolve_within_jail(self, relative: str | Path) -> Path: + """Resolve *relative* against the upload dir, refusing escapes. + + Raises ``ValueError`` when the resolved path lands outside the + upload directory (path traversal such as ``../../.env``, sibling + escapes, or absolute paths). Windows-style separators are rejected + outright: stored paths always use forward slashes. + """ + text = str(relative) + if "\x00" in text or "\\" in text: + raise ValueError("Invalid storage path.") + base = self.upload_dir.resolve() + candidate = (base / relative).resolve() + if not candidate.is_relative_to(base): + raise ValueError("Path escapes the upload directory.") + return candidate + + def _effective_max_bytes(self) -> float: + max_mb = self.max_size_mb if self.max_size_mb is not None else DEFAULT_MAX_SIZE_MB + return int(max_mb * 1024 * 1024) + async def save(self, file: UploadFile, directory: str = "") -> str: """Save an uploaded file. Returns the relative path within storage.""" + if directory and not _DIRECTORY_RE.match(directory): + raise ValueError("Invalid storage directory name.") + filename = self.sanitize_filename(file.filename or "unnamed") - target_dir = self.upload_dir / directory + + if directory: + target_dir = self._resolve_within_jail(directory) + else: + target_dir = self.upload_dir.resolve() target_dir.mkdir(parents=True, exist_ok=True) target_path = target_dir / filename + # Belt-and-braces: even a sanitized filename must stay jailed. + self._resolve_within_jail(target_path.relative_to(self.upload_dir.resolve())) + + max_bytes = self._effective_max_bytes() + max_mb = self.max_size_mb if self.max_size_mb is not None else DEFAULT_MAX_SIZE_MB + # Check the declared size BEFORE reading the body into RAM (DoS guard); + # fall back to a post-read check for streams that don't report size. + size = getattr(file, "size", None) + if size is not None and size > max_bytes: + raise ValueError( + f"File size ({size} bytes) exceeds maximum allowed size ({max_mb} MB)." + ) content = await file.read() - - if self.max_size_mb is not None: - max_bytes = int(self.max_size_mb * 1024 * 1024) - if len(content) > max_bytes: - raise ValueError( - f"File size ({len(content)} bytes) exceeds maximum " - f"allowed size ({self.max_size_mb} MB)." - ) + if len(content) > max_bytes: + raise ValueError( + f"File size ({len(content)} bytes) exceeds maximum allowed size ({max_mb} MB)." + ) with open(target_path, "wb") as f: f.write(content) @@ -59,8 +100,11 @@ async def save(self, file: UploadFile, directory: str = "") -> str: return filename async def delete(self, path: str) -> None: - """Delete a file at the given relative path.""" - target = self.upload_dir / path + """Delete a file at the given relative path. + + Raises ``ValueError`` when *path* escapes the upload directory. + """ + target = self._resolve_within_jail(path) if target.is_file(): os.remove(target) 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 #}