Add HyperAdmin to an existing FastAPI app
This guide adds HyperAdmin to a FastAPI application that already exists: it has its own models, its own database engine and migrations, its own lifespan and its own login. The admin plugs into all of them. It does not take over the app.
When you mount the admin at /admin, nothing outside /admin changes:
- nothing is mounted at the root (the admin's assets live under
/admin/static); - no middleware is added to the host app;
- no tables are created unless you ask for it;
- no cookie named
sessionis set, and your own session cookie is left alone; - admin route names (
hyperadmin:*) do not clash with yours; - your
/openapi.jsongets no admin entries.
A complete, runnable version of everything below is in
examples/byoa/.
Before you start
You need:
- a FastAPI app with SQLModel models (int, UUID or natural
strprimary keys all work); - an async SQLAlchemy engine (
sqlite+aiosqlite,postgresql+asyncpg, ...) or anasync_sessionmakerbuilt on it; - a FastAPI dependency that returns the current user, or
Nonefor an anonymous request. Most apps already have one, for exampleget_current_user.
Install the package. The distribution is hyper-admin; the import name is hyperadmin:
pip install hyper-admin==0.5.0a1
The minimal wiring
from hyperadmin import Admin
from hyperadmin.auth import ExternalAuth
from hyperadmin.core.registry import site
from myapp.auth import get_current_user # your existing dependency
from myapp.db import sessionmaker # your async_sessionmaker
from myapp.main import app
from myapp.models import Country, Project
site.register(Project)
site.register(Country)
admin = Admin(
app,
session_factory=sessionmaker,
auth=ExternalAuth(get_user=get_current_user, login_url="/login"),
)
admin.mount("/admin")
Then set a secret key, which signs the admin's CSRF tokens:
export HYPERADMIN_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(32))')"
That is all. Start your app and open /admin/. An anonymous browser is sent to
/login?next=%2Fadmin%2F; once your login has run, get_current_user returns
the user and the admin opens.
Without a secret key, Admin(...) raises ValueError at startup: with auth=
the admin enforces CSRF, and the default key is refused. Set
HYPERADMIN_DEBUG=true to accept the default key during local development.
The rest of this guide explains each part and the choices you may want to make.
1. Register your models
site.register(Model) adds a model to the admin with inferred list columns,
search fields and filters. To configure a model, pass a ModelAdmin subclass:
from hyperadmin.core.model import ModelAdmin
class ProjectAdmin(ModelAdmin):
list_display = ["name", "country_code"]
site.register(Project, ProjectAdmin)
Register models before admin.mount(): the routes are built when the admin is
mounted.
Primary keys are detected from the SQLAlchemy mapper, so int, uuid.UUID
and natural str keys work in every view, form, inline, bulk action and popup.
A malformed key in a URL returns 404. Two limits apply to str keys:
- a key that contains
/cannot be reached (Starlette matches on the decoded path); - a key equal to a literal route segment (
create,choices, ...) is shadowed on GET by that route.
Keys cannot be changed after a row is created. A model with a composite primary key is shown as a list only.
2. Reuse your engine or session factory
Pass whichever your app already has:
admin = Admin(app, engine=engine, auth=...) # an AsyncEngine
admin = Admin(app, session_factory=sessionmaker, auth=...) # an async_sessionmaker
A host that only has a generator dependency such as get_session() can wrap it:
session_factory=asynccontextmanager(get_session).
When you pass neither, the admin runs in demo mode: it builds its own engine
from HYPERADMIN_DATABASE_URL (an in-memory SQLite database by default). That
is for trying the admin out, not for an existing app.
3. Bring your own auth
ExternalAuth(get_user=...) makes your dependency guard every admin route.
FastAPI resolves it as a normal sub-dependency, so its own dependencies
(OAuth2PasswordBearer, get_db), yield dependencies, sync functions and
app.dependency_overrides all work as they do on your own routes. A dependency
that reads request.app.state works too: the admin sub-application shares the
host's state.
By default a user may open the admin when is_superuser or is_staff is
truthy. Superusers get full access; every other user is view-only until you
map your own permissions with has_permission. A user who is not a superuser
can never change or delete rows of your user model unless has_permission
explicitly allows it.
How the admin gets the user depends on how your app authenticates:
| Your app authenticates with | What to do |
|---|---|
| A cookie (server-side session, signed cookie) | Nothing more. The browser sends the cookie to /admin too. |
| A bearer token held by an SPA | Hand the token to the admin once: TokenCookie() plus POST /admin/auth/session. |
| A bearer token, and you do not want to touch the frontend | Let the admin serve its own login form: TokenCookie(token_endpoint=...). |
For a bearer-only host that should not change its frontend at all, point the
admin at your token endpoint. The admin then serves a login form at
/admin/login, posts the credentials to that endpoint server side and keeps
the returned token in an HttpOnly cookie scoped to /admin:
from hyperadmin.auth import ExternalAuth, TokenCookie
auth = ExternalAuth(
get_user=get_current_user,
token_cookie=TokenCookie(token_endpoint="/token"), # or "auto", or a callable
)
Bring your own auth covers every option, including the token handoff, logout, permissions and WebSockets.
4. Compose the admin lifespan
The admin has a small startup step (table creation if you opted in, and permission sync for built-in auth) and a shutdown step (draining realtime connections). Compose both into your own lifespan:
from contextlib import asynccontextmanager
@asynccontextmanager
async def lifespan(app: FastAPI):
# ... your own startup ...
async with admin.lifespan():
yield
# ... your own shutdown ...
app = FastAPI(lifespan=lifespan)
If you skip this, the first admin request runs the startup step for you. There
is no such safety net for shutdown: if you enable realtime
(Admin(realtime=RealtimeSettings(...))), you must compose admin.lifespan(),
or open SSE and WebSocket connections are not drained on a graceful shutdown.
The admin logs a one-time warning when realtime is on and startup came from the
first-request safety net.
admin.startup() and admin.shutdown() are also available if you prefer to
call them yourself. Both are idempotent.
5. Tables and migrations
The admin never runs DDL against your database unless you ask it to.
create_tables defaults to False, so your migrations stay the only thing that
changes your schema.
- Your own models: keep creating them with Alembic (or your own lifespan).
- Bring-your-own-auth mode: the admin needs no tables of its own. The built-in
hyperadmin_*user, group and permission tables are not even registered onSQLModel.metadata, so Alembic autogenerate does not see them. - Built-in auth (
auth_backend=): create its tables withhyperadmin init-db --database-url URL, or includehyperadmin.auth.metadata()in your Alembictarget_metadata.
Set HYPERADMIN_CREATE_TABLES=true to have startup create every table on
SQLModel.metadata, or call await admin.create_tables(scope="hyperadmin")
for the built-in auth tables only.
When a table is missing, the list page shows a banner telling you to run your
migrations or hyperadmin init-db, and the error is logged with its traceback.
Upgrading the built-in auth tables
HyperAdmin ships no migration tool, so a column added to a hyperadmin_* table
in a new release is created on fresh databases by hyperadmin init-db and picked
up by Alembic autogenerate through hyperadmin.auth.metadata(). Existing
databases add it with their own migration. The column each release adds:
| Since | Table | Column | Alembic operation |
|---|---|---|---|
| v0.5.7 | hyperadmin_groups |
permissions_version |
op.add_column("hyperadmin_groups", sa.Column("permissions_version", sa.Integer(), nullable=False, server_default="0")) |
The downgrade is op.drop_column("hyperadmin_groups", "permissions_version").
The same release adds two tables for the permission matrix, both part of
hyperadmin.auth.metadata() once hyperadmin.permissions is imported (the
matrix page imports it when it is registered):
| Since | Table | Purpose |
|---|---|---|
| v0.5.7 | hyperadmin_permission_audit |
one row per permission change: actor_key, group_id, action, model, codename, object_pk, before, after, created_at |
| v0.5.7 | hyperadmin_group_object_permissions |
the matrix's object rules: group_id, model, codename, object_pk, unique together |
Alembic autogenerate produces their create_table operations; written by
hand they are:
op.create_table(
"hyperadmin_permission_audit",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("actor_key", sa.String(255), nullable=False),
sa.Column("group_id", sa.Integer(), nullable=False),
sa.Column("action", sa.String(64), nullable=False),
sa.Column("model", sa.String(255), nullable=False),
sa.Column("codename", sa.String(255), nullable=False),
sa.Column("object_pk", sa.String(255), nullable=True),
sa.Column("before", sa.Boolean(), nullable=False),
sa.Column("after", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
)
op.create_index("ix_hyperadmin_permission_audit_actor_key", "hyperadmin_permission_audit", ["actor_key"])
op.create_index("ix_hyperadmin_permission_audit_group_id", "hyperadmin_permission_audit", ["group_id"])
op.create_table(
"hyperadmin_group_object_permissions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("group_id", sa.Integer(), sa.ForeignKey("hyperadmin_groups.id"), nullable=False),
sa.Column("model", sa.String(255), nullable=False),
sa.Column("codename", sa.String(255), nullable=False),
sa.Column("object_pk", sa.String(255), nullable=False),
sa.UniqueConstraint("group_id", "model", "codename", "object_pk", name="uq_group_object_rule"),
)
op.create_index("ix_hyperadmin_group_object_permissions_group_id", "hyperadmin_group_object_permissions", ["group_id"])
The downgrade is op.drop_table("hyperadmin_group_object_permissions") and
op.drop_table("hyperadmin_permission_audit"). Set permissions_matrix_enabled=False on
HyperAdminSettings to leave the matrix (and its sidebar entry) out of an
admin that has no groups to edit; the codename it gates on is
permissions_matrix_codename (default change_groups_permissions, superusers
always pass).
v0.5.6 adds one table for the filter library's saved views. It is part of
hyperadmin.auth.metadata() and of SQLModel.metadata as soon as the admin
views are imported (every mounted admin, in both auth modes), so autogenerate
sees it:
| Since | Table | Purpose |
|---|---|---|
| v0.5.6 | hyperadmin_saved_view |
one row per saved filter set: user_key, model, name, querystring, is_default, created_at; (user_key, model, name) unique |
Written by hand:
op.create_table(
"hyperadmin_saved_view",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("user_key", sa.String(255), nullable=False),
sa.Column("model", sa.String(255), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("querystring", sa.String(), nullable=False),
sa.Column("is_default", sa.Boolean(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.UniqueConstraint("user_key", "model", "name", name="uq_hyperadmin_saved_view_name"),
)
op.create_index("ix_hyperadmin_saved_view_user_key", "hyperadmin_saved_view", ["user_key"])
op.create_index("ix_hyperadmin_saved_view_model", "hyperadmin_saved_view", ["model"])
The downgrade is op.drop_table("hyperadmin_saved_view"). Until the table exists,
the list page logs a warning and leaves the saved-views panel out; set
AdminOptions(saved_views_enabled=False) on a model to leave its saved-view
endpoints unregistered as well. user_key is the stringified
request.state.user_key, so saved views work for host users in bring-your-own-auth
mode too.
permissions_version is the permission matrix's optimistic-concurrency stamp:
every grid an operator loads carries the group's current value, and a save is
only committed (and the stamp bumped, atomically with the permission rows) when
that value is still current. A stale grid therefore cannot overwrite a newer
save; it gets a 409 and reloads. Existing groups start at 0. See
docs/specs/permission-matrix-ui.md.
6. Datetimes and timezones
Naive and timezone-aware columns both work, on every supported sqlmodel version
(including sqlmodel's own UTCDateTime). Aware values are shown and entered in
HYPERADMIN_TIMEZONE (default UTC). To use a per-user zone, set
request.state.hyperadmin_timezone = ZoneInfo("Europe/Amsterdam") in a
middleware or dependency of your own.
7. What lives under /admin
The admin is mounted as a private sub-application:
- its stylesheets and scripts are served from
/admin/static, so your/staticstays yours; - its route names are namespaced: use
app.url_path_for("hyperadmin:project-list")from host code; - its cookies (
hyperadmin_csrf,hyperadmin_token,hyperadmin_locale) are scoped to the admin path, which follows your app'sroot_pathwhen it runs behind a path prefix; - uploaded files are served per record at
/admin/{model}/{id}/file/{field}, behind the admin's permission checks; admin.asgi_appis the sub-application, if you want to add your own routes behind the admin's middleware.
8. Going to production
- Secret key. Set
HYPERADMIN_SECRET_KEYto a long random value. - TLS-terminating proxy. The CSRF
Origincheck compares hosts, not schemes, so an https browser behind a proxy that talks plain http to your app is not refused. Still setHYPERADMIN_COOKIE_SECURE=true, or start uvicorn with--forwarded-allow-ipsset to your proxy's address so the app seeshttpsand marks cookiesSecureon its own. - Other origins. If the admin is reached through another hostname than the
one in the request's
Hostheader, list it inHYPERADMIN_CSRF_TRUSTED_ORIGINS(a JSON list, for example'["https://admin.example.com"]'). - Custom templates. Every built-in page and HTMX request carries the CSRF
token. A custom template with a raw
<form method="post">needs{{ csrf_input() }}inside the form; your own JavaScript can read the token from<meta name="csrf-token">and send it asX-CSRF-Token.
9. Testing your app
Your existing tests keep working:
app.dependency_overrides[get_current_user] = lambda: fake_userapplies under/admintoo, because the admin shares the host's overrides;- an unsafe admin request (POST, DELETE, ...) needs the CSRF token. Read it from
<meta name="csrf-token">on any admin page and send it asX-CSRF-Token, or build the admin withHyperAdminSettings(csrf_mode="off")in tests.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
ValueError: Auth or CSRF enforcement is enabled but no secret_key is configured |
Set HYPERADMIN_SECRET_KEY. |
| 401 page "Sign in to the host application" | get_user returned None and neither login_url nor TokenCookie(token_endpoint=...) is set. Set one of them. |
403 page naming can_access |
The user is signed in but is neither is_superuser nor is_staff. Pass your own ExternalAuth(can_access=...). |
403 on every save, with header X-HyperAdmin-CSRF: failed |
A custom form or script sends no CSRF token, or a proxy changes the host. See "Going to production". |
| 422 on every admin page | get_user needs a query or path parameter. It must resolve from headers and cookies alone. |
| A list page shows a "table does not exist" banner | Run your migrations (or hyperadmin init-db for built-in auth tables). |
Where the code differs from the design document
This guide follows the code as released. Two details differ from
docs/specs/bring-your-own-app.md:
admin.create_tables()defaults toscope="all"(every table onSQLModel.metadata), not"hyperadmin". Pass the scope explicitly.- The one-time warning about an https
Originon a plain-http request is logged by the built-in session middleware only, so a bring-your-own-auth admin behind a TLS proxy logs nothing. SetHYPERADMIN_COOKIE_SECURE=trueyourself.