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:
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:
HttpOnlyalways set.Secureflagged whencookie_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
logoutin Phase 1). secret_keymust be at least 32 bytes — short keys raiseValueErrorat 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.