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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
69 changes: 69 additions & 0 deletions docs/guide/alembic-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
176 changes: 176 additions & 0 deletions docs/guide/custom-auth-model.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions docs/guide/existing-alembic-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
13 changes: 13 additions & 0 deletions docs/guide/model-registration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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("<model>", "<permission>")` dependency). You can also pass
arbitrary dependencies directly:
Expand All @@ -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.
Expand Down
Loading
Loading