Skip to content

Admin site

Django-style management UI mounted under /admin. Operators sign in with a user row from the database (no separate admin password store) and browse every registered model from the browser, so the database port can stay closed on private networks. Phase 1 ships read-only views; create/edit/delete land in 0.14.0 and inline + bulk actions in 0.15.0.

Requires the [admin] extra:

pip install "tempest-fastapi-sdk[admin]"

1. User model

Subclass BaseUserModel to get the four columns the admin auth backend expects (email, hashed_password, is_admin, last_login_at) on top of the standard BaseModel row:

# src/db/models/user.py
from tempest_fastapi_sdk import BaseUserModel


class UserModel(BaseUserModel):
    __tablename__ = "users"   # scaffold convention; admin slug derives from __tablename__

set_password() / check_password() delegate to PasswordUtils; normalize_email() lowercases and strips. The default is_active (inherited from BaseModel) and is_admin (defaults to False) gate access — only is_active=True AND is_admin=True rows may sign in.

Bootstrap the first admin via your CLI / migration / seed script. The full script wires an AsyncDatabaseManager, opens one session, inserts the row and commits — exactly the same pattern your repositories follow at runtime:

# scripts/create_admin.py
import asyncio

from tempest_fastapi_sdk import AsyncDatabaseManager

from src.core.settings import settings
from src.db.models import UserModel


async def main() -> None:
    db = AsyncDatabaseManager(settings.DATABASE_URL)
    await db.connect()
    try:
        async with db.get_session_context() as session:
            # ──────── the only admin-specific lines ────────
            admin = UserModel(email="root@example.com", is_admin=True)
            admin.set_password("hunter2")  # bcrypt via PasswordUtils
            session.add(admin)
            await session.commit()
    finally:
        await db.disconnect()


if __name__ == "__main__":
    asyncio.run(main())

The four highlighted lines under the divider comment are the only admin-bootstrap code; everything around them is the standard async DB lifecycle the SDK already uses.

2. Register your admin classes

AdminModel is a plain typed configuration instance — the constructor signature is the contract (no class-attribute / metaclass magic), and every field accepts a real SQLAlchemy column attribute (UserModel.email), so typos surface in your editor instead of at runtime. The defaults work out of the box; pass the fields you want to enrich the list view:

# src/admin/site.py
from sqlalchemy import desc

from tempest_fastapi_sdk import AdminModel, AdminSite

from src.db.models import UserModel, OrderModel

site = AdminSite(
    title="MyApp Admin",
    index_subtitle="Site administration",
    site_url="https://myapp.com",   # optional outbound "View site" link
)

site.register(AdminModel(
    model=UserModel,
    list_display=[UserModel.email, UserModel.is_admin, UserModel.is_active, UserModel.last_login_at],
    list_filter=[UserModel.is_active, UserModel.is_admin],
    search_fields=[UserModel.email],
    readonly_fields=[UserModel.id, UserModel.hashed_password, UserModel.created_at, UserModel.updated_at],
    ordering=desc(UserModel.created_at),
    page_size=25,
))

Every field reference also accepts a plain string (list_display=["email", ...]) for dynamic configuration, and ordering accepts a column (ascending), desc(column) / asc(column), or a Django-style "-created_at" string. register returns the instance and raises ValueError on a duplicate slug. Slugs default to the model's __tablename__ so URLs and database tables stay in sync.

3. Mount the router

# src/api/app.py
from fastapi import FastAPI

from tempest_fastapi_sdk import (
    AsyncDatabaseManager,
    UserModelAuthBackend,
    make_admin_router,
)

from src.admin.site import site
from src.core.settings import settings
from src.db.models import UserModel

db = AsyncDatabaseManager(settings.DATABASE_URL)
app = FastAPI()
app.include_router(
    make_admin_router(
        site,
        db=db,
        auth_backend=UserModelAuthBackend(UserModel),
        secret_key=settings.JWT_SECRET,          # scaffold reuses JWT_SECRET — at least 32 bytes
        prefix="/admin",
        cookie_secure=not settings.DEBUG,        # True in production HTTPS
    )
)

make_admin_router mounts:

  • GET /admin/login, POST /admin/login, POST /admin/logout — auth flow.
  • GET /admin/ — dashboard listing every registered admin.
  • GET /admin/m/{slug}/ — list view with pagination + free-text search (?q=) + per-field filters (?filter_<field>=value).
  • GET /admin/m/{slug}/{identity} — read-only detail view.
  • GET /admin/static/{path} — bundled CSS/HTMX assets.

4. Session security defaults

SignedCookieSessionStore uses itsdangerous.TimestampSigner (HMAC-SHA256) to sign a single cookie:

  • HttpOnly always set.
  • Secure flagged when cookie_secure=True (default; flip off in local HTTP dev).
  • SameSite=Lax ("lax"/"strict"/"none" accepted).
  • Default lifetime 8h; expired or tampered cookies are rejected silently.
  • Per-session CSRF token is generated at login and required by every form POST (only logout in Phase 1).
  • secret_key must be at least 32 bytes — short keys raise ValueError at construction time.

5. Plug in a custom auth backend

AdminAuthBackend is an ABC, so swap the default for LDAP / OAuth / external IAM by subclassing:

from typing import Any

from sqlalchemy.ext.asyncio import AsyncSession

from tempest_fastapi_sdk import AdminAuthBackend, AdminAuthError


class OAuthAdminBackend(AdminAuthBackend):
    async def authenticate(
        self,
        session: AsyncSession,
        *,
        identifier: str,
        password: str,
    ) -> Any:
        principal = await my_oauth_client.authenticate(identifier, password)
        if not principal.has_role("admin"):
            raise AdminAuthError("not an admin")
        return principal

    async def load_principal(
        self,
        session: AsyncSession,
        principal_id: str,
    ) -> Any | None:
        return await my_oauth_client.get_user(principal_id)

    def principal_id(self, principal: Any) -> str:
        return principal.sub

    def display_name(self, principal: Any) -> str:
        return principal.email

Pass the instance via auth_backend= and the rest of the admin pipeline (sessions, dashboard, list, detail) keeps working unchanged.