Initial Claude implementation

This commit is contained in:
Ian Roddis committed 2026-06-22 14:26:44 -03:00
commit e2d3ab9c2a
63 files changed
+3930

No files matched your search

+16
View File
@@ -0,0 +1,16 @@
.git
.gitignore
.venv
venv
__pycache__
*.py[cod]
.pytest_cache
.ruff_cache
.env
*.db
*.sqlite3
.DS_Store
.idea
.vscode
README.md
tests
+34
View File
@@ -0,0 +1,34 @@
# Copy to .env and adjust. Every value here can also be supplied as a real
# environment variable (which takes precedence over the .env file).
# --- General ---
APP_NAME="FastAPI Skeleton"
DEBUG=false
# --- Security ---
# Generate a strong value, e.g. `python -c "import secrets; print(secrets.token_urlsafe(48))"`
SECRET_KEY="change-me-please-use-a-long-random-string"
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=15
REFRESH_TOKEN_EXPIRE_DAYS=7
# --- Database ---
# SQLite (zero-setup default):
DATABASE_URL="sqlite+aiosqlite:///./app.db"
# Postgres example:
# DATABASE_URL="postgresql+asyncpg://app:app@localhost:5432/app"
# Create tables on startup (handy for dev/SQLite; use Alembic in production).
AUTO_CREATE_TABLES=true
# --- Web / cookies ---
COOKIE_NAME=access_token
# Set true when serving over HTTPS.
COOKIE_SECURE=false
# --- CORS (JSON list) ---
CORS_ORIGINS=["*"]
# --- Bootstrap superuser (used by scripts/seed.py) ---
FIRST_SUPERUSER_EMAIL=admin@example.com
FIRST_SUPERUSER_PASSWORD=changethis
+24
View File
@@ -0,0 +1,24 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.venv/
venv/
# Env / secrets
.env
# Databases
*.db
*.sqlite3
# Test / tooling
.pytest_cache/
.ruff_cache/
.coverage
htmlcov/
# OS / editor
.DS_Store
.idea/
.vscode/
+70
View File
@@ -0,0 +1,70 @@
# CLAUDE.md
Guidance for working in this repo. Read alongside `README.md` (user-facing setup).
## What this is
A FastAPI skeleton serving **both** a JSON API (`/api/*`) and server-rendered
HTML via **Jinja2 + HTMX** (`/`, `/admin/*`). Async SQLAlchemy 2.0 ORM, JWT auth
with refresh-token revocation, and RBAC (users → roles → permissions).
## Commands
```bash
uv sync --extra dev # install (incl. dev tools)
uv run python -m scripts.seed # seed perms/roles/superuser (idempotent)
uv run uvicorn app.main:app --reload # dev server on :8000
uv run pytest # tests (in-memory SQLite)
uv run ruff check . [--fix] # lint
uv run alembic upgrade head # apply migrations
uv run alembic revision --autogenerate -m "msg" # new migration after model changes
docker compose up --build # app + Postgres, auto-seeded
```
## Architecture / layering
Request → router (`app/api/` JSON or `app/web/` HTML) → `app/crud/` (data access)
→ `app/models/`. Schemas in `app/schemas/`, cross-cutting DI in `app/deps.py`,
token logic in `app/services/auth.py`. Keep route handlers thin; put DB logic in
`crud/`, not in routers.
## Conventions that matter
- **Everything async.** Engine, sessions, CRUD, routes, tests. Use `await`; never
add a sync DB call. `get_db` (in `app/core/database.py`) yields the request session.
- **Relationships use `lazy="selectin"`** (`User.roles`, `Role.permissions`) so
attributes are safe to read after the session closes — required under async.
Preserve this when adding relationships that templates/schemas read.
- **Config** is `app/core/config.py` (`pydantic-settings`). Add new settings there;
they're auto-read from env vars (uppercased field name) or `.env`. Env wins.
- **RBAC**: guard API routes with `Depends(require_permission("resource:action"))`;
guard web routes with `web_require(...)` in `app/web/admin.py`. Superusers bypass
all checks (`User.is_superuser` / `User.has_permission`). Add new permission
strings to `scripts/seed.py`'s `PERMISSIONS`/`ROLES`.
- **Auth, two transports**: API uses `Authorization: Bearer`; the HTMX UI uses an
httpOnly cookie (`settings.cookie_name`). `app/deps.py` reads both. Web auth
failures raise `NotAuthenticated`, handled in `main.py` as a redirect to `/login`.
- **Refresh tokens** are stored server-side (hash + `jti` + `revoked`) in the
`refresh_tokens` table. `services/auth.rotate_refresh_token` revokes-then-reissues.
Note: JWT payload `exp` is a tz-aware `datetime` (PyJWT only epoch-encodes inside
the token) — pass it straight to the model, don't `fromtimestamp` it.
## Database
One `DATABASE_URL` drives SQLite (`sqlite+aiosqlite://`, the default) or Postgres
(`postgresql+asyncpg://`). `AUTO_CREATE_TABLES=true` calls `Base.metadata.create_all`
on startup for dev convenience; production should use Alembic instead. `init_db()`
imports `app.models` inside the function to avoid a circular import.
## Tests
`tests/conftest.py` builds an in-memory SQLite engine with `StaticPool`, creates
tables, and overrides `get_db`. Fixtures: `client` (httpx ASGI), `seeded`
(perms+roles), `make_user`. `asyncio_mode = "auto"` — write `async def test_*`
without decorators.
## When extending
Add a feature → model in `models/` (+ register in `models/__init__.py`), schema in
`schemas/`, CRUD in `crud/`, routes in `api/` and/or `web/`, then a migration. If it
needs access control, add the permission to the seed and guard the routes.
+25
View File
@@ -0,0 +1,25 @@
# syntax=docker/dockerfile:1
FROM python:3.12-slim
# Install uv (fast Python package manager).
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy
WORKDIR /app
# Install dependencies first for better layer caching.
COPY pyproject.toml ./
COPY uv.lock* ./
RUN uv sync --no-dev
# Copy the application.
COPY . .
EXPOSE 8000
# Default: run the API server. docker-compose overrides this to also seed.
CMD ["uv", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+144
View File
@@ -0,0 +1,144 @@
# FastAPI Skeleton
A production-shaped starting point for a web app that serves both **HTML (HTMX)** and a **JSON API**.
## Stack
| Concern | Choice |
|----------------|--------|
| Web framework | FastAPI |
| HTML / interactivity | Jinja2 + HTMX |
| ORM | SQLAlchemy 2.0 (async) |
| Migrations | Alembic |
| Database | SQLite (default) or Postgres — one `DATABASE_URL` |
| Auth | JWT access + refresh tokens, rotation, DB-backed revocation |
| Authorization | RBAC: users → roles → permissions |
| Admin UI | Custom HTMX dashboard (user & role CRUD) |
| Config | env vars **or** `.env` (pydantic-settings) |
| Tooling | uv, pytest, ruff |
| Packaging | Dockerfile + docker-compose |
## Layout
```
app/
core/ config, database engine/session, security (hashing + JWT)
models/ SQLAlchemy models: user, role, permission, refresh_token
schemas/ Pydantic request/response models
crud/ data-access helpers
services/ auth token issuance / rotation
deps.py DI: get_db, current user, require_permission(...)
api/ JSON API: auth, users, roles
web/ HTML/HTMX routes: pages + admin dashboard
templates/ Jinja2 templates (+ admin partials)
static/ CSS
alembic/ migration environment + initial migration
scripts/seed.py bootstrap permissions, roles, first superuser
tests/ pytest (auth + RBAC)
```
## Quick start (local, SQLite)
```bash
# 1. Install dependencies (https://docs.astral.sh/uv/)
uv sync --extra dev
# 2. Configure (optional — sensible defaults exist)
cp .env.example .env # then edit SECRET_KEY etc.
# 3. Seed default roles/permissions + the first superuser
uv run python -m scripts.seed
# 4. Run
uv run uvicorn app.main:app --reload
```
Open:
- http://localhost:8000/ — HTMX dashboard (log in with the seeded superuser)
- http://localhost:8000/docs — interactive API docs
- http://localhost:8000/admin/users — admin dashboard
Default superuser (override via env/`.env`): `admin@example.com` / `changethis`.
## Run with Docker (Postgres)
```bash
docker compose up --build
```
This starts Postgres, seeds the database, and serves the app on
http://localhost:8000. The app connects with
`DATABASE_URL=postgresql+asyncpg://app:app@db:5432/app`.
## Configuration
Every setting is read from an environment variable first, then `.env`
(see `.env.example`). Key ones:
| Variable | Default | Notes |
|----------|---------|-------|
| `SECRET_KEY` | dev placeholder | **set a strong value in production** |
| `DATABASE_URL` | `sqlite+aiosqlite:///./app.db` | use `postgresql+asyncpg://...` for Postgres |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | `15` | |
| `REFRESH_TOKEN_EXPIRE_DAYS` | `7` | |
| `AUTO_CREATE_TABLES` | `true` | create tables on startup (dev); use Alembic in prod |
| `COOKIE_SECURE` | `false` | set `true` behind HTTPS |
| `FIRST_SUPERUSER_EMAIL` / `_PASSWORD` | admin@example.com / changethis | used by seed |
## Auth model
- `POST /api/auth/register` — self-service signup (gets the default `user` role)
- `POST /api/auth/login` — OAuth2 password form (`username` = email) → access + refresh
- `POST /api/auth/refresh` — rotates: the presented refresh token is revoked, a new pair is issued
- `POST /api/auth/logout` — revoke one refresh token (body) or all for the user
The JSON API authenticates via `Authorization: Bearer <access>`. The HTMX UI
stores the access token in an httpOnly cookie so pages work without client JS.
Refresh tokens are stored server-side as a SHA-256 hash + `jti` with a `revoked`
flag, so logout and rotation genuinely invalidate them.
## RBAC
`users → roles → permissions`. Permissions follow `resource:action`
(`users:read`, `users:write`, `users:delete`, `roles:read`, `roles:write`).
Protect an endpoint with the dependency factory:
```python
@router.get("", dependencies=[Depends(require_permission("users:read"))])
async def list_users(...): ...
```
Superusers (`is_superuser=True`) bypass all permission checks.
## Migrations (Alembic)
`AUTO_CREATE_TABLES` is convenient for dev/SQLite. For real deployments use
migrations instead:
```bash
# apply the bundled initial migration
uv run alembic upgrade head
# after changing models, autogenerate the next revision
uv run alembic revision --autogenerate -m "describe change"
uv run alembic upgrade head
```
The Alembic env reads `DATABASE_URL` from app settings, so it targets the same
database as the app.
## Tests
```bash
uv run pytest
```
Tests run against an in-memory SQLite database and cover the auth lifecycle
(register/login/me/refresh/logout) and RBAC enforcement.
## Where to go next
- Add your own models in `app/models/`, schemas in `app/schemas/`, routes in `app/api/` or `app/web/`.
- Add permissions to `scripts/seed.py` and guard routes with `require_permission(...)`.
- Swap `HS256` for asymmetric `RS256` keys if you need to verify tokens in other services.
+40
View File
@@ -0,0 +1,40 @@
# Alembic configuration. The database URL is injected at runtime from
# app settings (see alembic/env.py), so it is intentionally left blank here.
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARNING
handlers = console
qualname =
[logger_sqlalchemy]
level = WARNING
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+62
View File
@@ -0,0 +1,62 @@
"""Alembic environment configured for async SQLAlchemy.
The DB URL and target metadata come from the application so migrations always
match the models and the active configuration.
"""
import asyncio
from logging.config import fileConfig
from sqlalchemy import pool
from sqlalchemy.ext.asyncio import async_engine_from_config
import app.models # noqa: F401 (register models on Base.metadata)
from alembic import context
from app.core.config import settings
from app.core.database import Base
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url)
if config.config_file_name is not None:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=settings.database_url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def do_run_migrations(connection) -> None:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True, # needed for SQLite ALTER support
)
with context.begin_transaction():
context.run_migrations()
async def run_migrations_online() -> None:
connectable = async_engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
async with connectable.connect() as connection:
await connection.run_sync(do_run_migrations)
await connectable.dispose()
if context.is_offline_mode():
run_migrations_offline()
else:
asyncio.run(run_migrations_online())
+25
View File
@@ -0,0 +1,25 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+102
View File
@@ -0,0 +1,102 @@
"""initial schema
Revision ID: 0001_initial
Revises:
Create Date: 2026-01-01 00:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0001_initial"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"users",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("email", sa.String(length=255), nullable=False),
sa.Column("hashed_password", sa.String(length=255), nullable=False),
sa.Column("full_name", sa.String(length=255), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
sa.Column("is_superuser", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index("ix_users_email", "users", ["email"], unique=True)
op.create_table(
"roles",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=64), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
)
op.create_index("ix_roles_name", "roles", ["name"], unique=True)
op.create_table(
"permissions",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("name", sa.String(length=128), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
)
op.create_index("ix_permissions_name", "permissions", ["name"], unique=True)
op.create_table(
"user_roles",
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("role_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("user_id", "role_id"),
)
op.create_table(
"role_permissions",
sa.Column("role_id", sa.Integer(), nullable=False),
sa.Column("permission_id", sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(["role_id"], ["roles.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["permission_id"], ["permissions.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("role_id", "permission_id"),
)
op.create_table(
"refresh_tokens",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("jti", sa.String(length=64), nullable=False),
sa.Column("token_hash", sa.String(length=64), nullable=False),
sa.Column("user_id", sa.Integer(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("revoked", sa.Boolean(), nullable=False, server_default=sa.false()),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
)
op.create_index("ix_refresh_tokens_jti", "refresh_tokens", ["jti"], unique=True)
op.create_index("ix_refresh_tokens_user_id", "refresh_tokens", ["user_id"])
def downgrade() -> None:
op.drop_index("ix_refresh_tokens_user_id", table_name="refresh_tokens")
op.drop_index("ix_refresh_tokens_jti", table_name="refresh_tokens")
op.drop_table("refresh_tokens")
op.drop_table("role_permissions")
op.drop_table("user_roles")
op.drop_index("ix_permissions_name", table_name="permissions")
op.drop_table("permissions")
op.drop_index("ix_roles_name", table_name="roles")
op.drop_table("roles")
op.drop_index("ix_users_email", table_name="users")
op.drop_table("users")
View File
Whitespace-only changes.
View File
Whitespace-only changes.
+82
View File
@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import role as role_crud
from app.crud import token as token_crud
from app.crud import user as user_crud
from app.deps import get_current_user
from app.models.user import User
from app.schemas.token import LogoutRequest, Token, TokenRefreshRequest
from app.schemas.user import RegisterRequest, UserRead
from app.services.auth import RefreshError, issue_token_pair, rotate_refresh_token
router = APIRouter()
DEFAULT_USER_ROLE = "user"
@router.post("/register", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def register(payload: RegisterRequest, db: AsyncSession = Depends(get_db)):
if await user_crud.get_by_email(db, payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
default_role = await role_crud.get_by_name(db, DEFAULT_USER_ROLE)
role_ids = [default_role.id] if default_role else []
user = await user_crud.create(
db,
email=payload.email,
password=payload.password,
full_name=payload.full_name,
role_ids=role_ids,
)
return user
@router.post("/login", response_model=Token)
async def login(
form: OAuth2PasswordRequestForm = Depends(),
db: AsyncSession = Depends(get_db),
):
# OAuth2 form uses ``username``; we treat it as the email.
user = await user_crud.authenticate(db, form.username, form.password)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
access, refresh = await issue_token_pair(db, user)
return Token(access_token=access, refresh_token=refresh)
@router.post("/refresh", response_model=Token)
async def refresh(payload: TokenRefreshRequest, db: AsyncSession = Depends(get_db)):
try:
access, refresh, _ = await rotate_refresh_token(db, payload.refresh_token)
except RefreshError as exc:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail=str(exc)
) from exc
return Token(access_token=access, refresh_token=refresh)
@router.post("/logout", status_code=status.HTTP_204_NO_CONTENT)
async def logout(
payload: LogoutRequest,
user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
if payload.refresh_token:
try:
from app.core.security import decode_token
jti = decode_token(payload.refresh_token).get("jti")
if jti:
await token_crud.revoke_by_jti(db, jti)
except Exception: # noqa: BLE001 - best-effort revocation of a single token
pass
else:
await token_crud.revoke_all_for_user(db, user.id)
+81
View File
@@ -0,0 +1,81 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import role as role_crud
from app.deps import require_permission
from app.models.user import User
from app.schemas.rbac import PermissionRead, RoleCreate, RoleRead, RoleUpdate
router = APIRouter()
@router.get("", response_model=list[RoleRead])
async def list_roles(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:read")),
):
return await role_crud.list_roles(db)
@router.post("", response_model=RoleRead, status_code=status.HTTP_201_CREATED)
async def create_role(
payload: RoleCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
):
if await role_crud.get_by_name(db, payload.name):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Role already exists"
)
return await role_crud.create(
db,
name=payload.name,
description=payload.description,
permission_ids=payload.permission_ids,
)
@router.get("/permissions", response_model=list[PermissionRead])
async def list_permissions(
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:read")),
):
return await role_crud.list_permissions(db)
@router.get("/{role_id}", response_model=RoleRead)
async def get_role(
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:read")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
return role
@router.patch("/{role_id}", response_model=RoleRead)
async def update_role(
role_id: int,
payload: RoleUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
return await role_crud.update(db, role, payload.model_dump(exclude_unset=True))
@router.delete("/{role_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_role(
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Role not found")
await role_crud.delete(db, role)
+8
View File
@@ -0,0 +1,8 @@
from fastapi import APIRouter
from app.api import auth, roles, users
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["auth"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
+82
View File
@@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import user as user_crud
from app.deps import get_current_user, require_permission
from app.models.user import User
from app.schemas.user import UserCreate, UserRead, UserUpdate
router = APIRouter()
@router.get("/me", response_model=UserRead)
async def read_me(user: User = Depends(get_current_user)):
return user
@router.get("", response_model=list[UserRead])
async def list_users(
skip: int = 0,
limit: int = 100,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:read")),
):
return await user_crud.list_users(db, skip=skip, limit=limit)
@router.post("", response_model=UserRead, status_code=status.HTTP_201_CREATED)
async def create_user(
payload: UserCreate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:write")),
):
if await user_crud.get_by_email(db, payload.email):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="Email already registered"
)
return await user_crud.create(
db,
email=payload.email,
password=payload.password,
full_name=payload.full_name,
is_superuser=payload.is_superuser,
role_ids=payload.role_ids,
)
@router.get("/{user_id}", response_model=UserRead)
async def get_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:read")),
):
user = await user_crud.get(db, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return user
@router.patch("/{user_id}", response_model=UserRead)
async def update_user(
user_id: int,
payload: UserUpdate,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:write")),
):
user = await user_crud.get(db, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return await user_crud.update(db, user, payload.model_dump(exclude_unset=True))
@router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(require_permission("users:delete")),
):
user = await user_crud.get(db, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
await user_crud.delete(db, user)
View File
Whitespace-only changes.
+52
View File
@@ -0,0 +1,52 @@
"""Application configuration.
Settings are read from environment variables first, then from a `.env` file.
Environment variables always take precedence over the file.
"""
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
extra="ignore",
case_sensitive=False,
)
# General
app_name: str = "FastAPI Skeleton"
debug: bool = False
# Security / JWT
secret_key: str = "change-me-please-use-a-long-random-string"
algorithm: str = "HS256"
access_token_expire_minutes: int = 15
refresh_token_expire_days: int = 7
# Database — single URL drives SQLite or Postgres.
# SQLite: sqlite+aiosqlite:///./app.db
# Postgres: postgresql+asyncpg://user:pass@host:5432/dbname
database_url: str = "sqlite+aiosqlite:///./app.db"
auto_create_tables: bool = True
# Web / cookies (the HTMX UI stores the access token in an httpOnly cookie)
cookie_name: str = "access_token"
cookie_secure: bool = False
# CORS
cors_origins: list[str] = ["*"]
# Bootstrap superuser (used by scripts/seed.py)
first_superuser_email: str = "admin@example.com"
first_superuser_password: str = "changethis"
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()
+46
View File
@@ -0,0 +1,46 @@
"""Async SQLAlchemy engine, session factory, and declarative base.
Works with SQLite (aiosqlite) or Postgres (asyncpg) depending on DATABASE_URL.
"""
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
class Base(DeclarativeBase):
"""Declarative base shared by all models."""
engine = create_async_engine(settings.database_url, echo=settings.debug, future=True)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency yielding a request-scoped session."""
async with AsyncSessionLocal() as session:
yield session
async def init_db() -> None:
"""Create all tables. Convenient for dev/SQLite and tests.
In production prefer Alembic migrations (``alembic upgrade head``).
"""
# Import models so they register with Base.metadata before create_all.
import app.models # noqa: F401
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+72
View File
@@ -0,0 +1,72 @@
"""Password hashing and JWT token helpers."""
import hashlib
import uuid
from datetime import UTC, datetime, timedelta
import bcrypt
import jwt
from app.core.config import settings
ACCESS_TOKEN_TYPE = "access"
REFRESH_TOKEN_TYPE = "refresh"
# --- Passwords ---------------------------------------------------------------
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(password: str, hashed: str) -> bool:
try:
return bcrypt.checkpw(password.encode(), hashed.encode())
except ValueError:
return False
# --- JWT ---------------------------------------------------------------------
def _create_token(
subject: str | int,
expires_delta: timedelta,
token_type: str,
) -> tuple[str, dict]:
now = datetime.now(UTC)
payload: dict = {
"sub": str(subject),
"iat": now,
"exp": now + expires_delta,
"type": token_type,
"jti": uuid.uuid4().hex,
}
token = jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
return token, payload
def create_access_token(subject: str | int) -> str:
token, _ = _create_token(
subject,
timedelta(minutes=settings.access_token_expire_minutes),
ACCESS_TOKEN_TYPE,
)
return token
def create_refresh_token(subject: str | int) -> tuple[str, dict]:
"""Return ``(token, payload)`` so the caller can persist jti/exp for revocation."""
return _create_token(
subject,
timedelta(days=settings.refresh_token_expire_days),
REFRESH_TOKEN_TYPE,
)
def decode_token(token: str) -> dict:
"""Decode and validate a JWT. Raises ``jwt.PyJWTError`` on failure."""
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
def hash_token(token: str) -> str:
"""Stable hash used to store refresh tokens without keeping the raw value."""
return hashlib.sha256(token.encode()).hexdigest()
View File
Whitespace-only changes.
+86
View File
@@ -0,0 +1,86 @@
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.permission import Permission
from app.models.role import Role
async def get(db: AsyncSession, role_id: int) -> Role | None:
return await db.get(Role, role_id)
async def get_by_name(db: AsyncSession, name: str) -> Role | None:
result = await db.execute(select(Role).where(Role.name == name))
return result.scalar_one_or_none()
async def list_roles(db: AsyncSession) -> list[Role]:
result = await db.execute(select(Role).order_by(Role.id))
return list(result.scalars().all())
async def _resolve_permissions(db: AsyncSession, ids: list[int]) -> list[Permission]:
if not ids:
return []
result = await db.execute(select(Permission).where(Permission.id.in_(ids)))
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
name: str,
description: str | None = None,
permission_ids: list[int] | None = None,
) -> Role:
role = Role(
name=name,
description=description,
permissions=await _resolve_permissions(db, permission_ids or []),
)
db.add(role)
await db.commit()
await db.refresh(role)
return role
async def update(db: AsyncSession, role: Role, data: dict) -> Role:
if "permission_ids" in data and data["permission_ids"] is not None:
role.permissions = await _resolve_permissions(db, data.pop("permission_ids"))
else:
data.pop("permission_ids", None)
for field, value in data.items():
if value is not None:
setattr(role, field, value)
await db.commit()
await db.refresh(role)
return role
async def delete(db: AsyncSession, role: Role) -> None:
await db.delete(role)
await db.commit()
# --- Permissions -------------------------------------------------------------
async def list_permissions(db: AsyncSession) -> list[Permission]:
result = await db.execute(select(Permission).order_by(Permission.name))
return list(result.scalars().all())
async def get_permission_by_name(db: AsyncSession, name: str) -> Permission | None:
result = await db.execute(select(Permission).where(Permission.name == name))
return result.scalar_one_or_none()
async def create_permission(
db: AsyncSession, *, name: str, description: str | None = None
) -> Permission:
perm = Permission(name=name, description=description)
db.add(perm)
await db.commit()
await db.refresh(perm)
return perm
+66
View File
@@ -0,0 +1,66 @@
from datetime import UTC, datetime
from sqlalchemy import select, update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_token
from app.models.refresh_token import RefreshToken
async def store(
db: AsyncSession,
*,
user_id: int,
jti: str,
raw_token: str,
expires_at: datetime,
) -> RefreshToken:
record = RefreshToken(
jti=jti,
token_hash=hash_token(raw_token),
user_id=user_id,
expires_at=expires_at,
)
db.add(record)
await db.commit()
await db.refresh(record)
return record
async def get_active(
db: AsyncSession, *, jti: str, raw_token: str
) -> RefreshToken | None:
"""Return the token row only if it exists, matches, is not revoked, not expired."""
result = await db.execute(select(RefreshToken).where(RefreshToken.jti == jti))
record = result.scalar_one_or_none()
if record is None or record.revoked:
return None
if record.token_hash != hash_token(raw_token):
return None
expires_at = record.expires_at
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(UTC):
return None
return record
async def revoke(db: AsyncSession, record: RefreshToken) -> None:
record.revoked = True
await db.commit()
async def revoke_by_jti(db: AsyncSession, jti: str) -> None:
await db.execute(
update(RefreshToken).where(RefreshToken.jti == jti).values(revoked=True)
)
await db.commit()
async def revoke_all_for_user(db: AsyncSession, user_id: int) -> None:
await db.execute(
update(RefreshToken)
.where(RefreshToken.user_id == user_id, RefreshToken.revoked.is_(False))
.values(revoked=True)
)
await db.commit()
+86
View File
@@ -0,0 +1,86 @@
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import hash_password, verify_password
from app.models.role import Role
from app.models.user import User
async def get(db: AsyncSession, user_id: int) -> User | None:
return await db.get(User, user_id)
async def get_by_email(db: AsyncSession, email: str) -> User | None:
result = await db.execute(select(User).where(User.email == email))
return result.scalar_one_or_none()
async def list_users(db: AsyncSession, skip: int = 0, limit: int = 100) -> list[User]:
result = await db.execute(select(User).order_by(User.id).offset(skip).limit(limit))
return list(result.scalars().all())
async def count(db: AsyncSession) -> int:
result = await db.execute(select(func.count()).select_from(User))
return result.scalar_one()
async def _resolve_roles(db: AsyncSession, role_ids: list[int]) -> list[Role]:
if not role_ids:
return []
result = await db.execute(select(Role).where(Role.id.in_(role_ids)))
return list(result.scalars().all())
async def create(
db: AsyncSession,
*,
email: str,
password: str,
full_name: str | None = None,
is_superuser: bool = False,
role_ids: list[int] | None = None,
) -> User:
user = User(
email=email,
hashed_password=hash_password(password),
full_name=full_name,
is_superuser=is_superuser,
roles=await _resolve_roles(db, role_ids or []),
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
async def update(db: AsyncSession, user: User, data: dict) -> User:
if "password" in data and data["password"]:
user.hashed_password = hash_password(data.pop("password"))
else:
data.pop("password", None)
if "role_ids" in data and data["role_ids"] is not None:
user.roles = await _resolve_roles(db, data.pop("role_ids"))
else:
data.pop("role_ids", None)
for field, value in data.items():
if value is not None:
setattr(user, field, value)
await db.commit()
await db.refresh(user)
return user
async def delete(db: AsyncSession, user: User) -> None:
await db.delete(user)
await db.commit()
async def authenticate(db: AsyncSession, email: str, password: str) -> User | None:
user = await get_by_email(db, email)
if not user or not verify_password(password, user.hashed_password):
return None
return user
+106
View File
@@ -0,0 +1,106 @@
"""Shared FastAPI dependencies: DB session, current user, RBAC enforcement."""
import jwt
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.core.security import ACCESS_TOKEN_TYPE, decode_token
from app.crud import user as user_crud
from app.models.user import User
# auto_error=False so we can fall back to the cookie used by the HTMX UI.
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/auth/login", auto_error=False)
class NotAuthenticated(Exception):
"""Raised by web dependencies; handled by redirecting to the login page."""
def _token_from_request(request: Request, bearer: str | None) -> str | None:
if bearer:
return bearer
return request.cookies.get(settings.cookie_name)
async def _user_from_token(token: str | None, db: AsyncSession) -> User | None:
if not token:
return None
try:
payload = decode_token(token)
except jwt.PyJWTError:
return None
if payload.get("type") != ACCESS_TOKEN_TYPE:
return None
sub = payload.get("sub")
if sub is None:
return None
user = await user_crud.get(db, int(sub))
if user is None or not user.is_active:
return None
return user
# --- JSON API auth -----------------------------------------------------------
async def get_current_user(
request: Request,
bearer: str | None = Depends(oauth2_scheme),
db: AsyncSession = Depends(get_db),
) -> User:
user = await _user_from_token(_token_from_request(request, bearer), db)
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not authenticated",
headers={"WWW-Authenticate": "Bearer"},
)
return user
def require_permission(*required: str):
"""Dependency factory enforcing that the user holds all given permissions."""
async def checker(user: User = Depends(get_current_user)) -> User:
if user.is_superuser:
return user
missing = set(required) - user.permissions
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing permission(s): {', '.join(sorted(missing))}",
)
return user
return checker
def require_superuser(user: User = Depends(get_current_user)) -> User:
if not user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, detail="Superuser required"
)
return user
# --- Web (HTMX) auth ---------------------------------------------------------
async def get_current_web_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User:
"""Like get_current_user but reads the cookie and redirects on failure."""
token = request.cookies.get(settings.cookie_name)
user = await _user_from_token(token, db)
if user is None:
raise NotAuthenticated()
return user
async def get_optional_web_user(
request: Request,
db: AsyncSession = Depends(get_db),
) -> User | None:
token = request.cookies.get(settings.cookie_name)
return await _user_from_token(token, db)
+55
View File
@@ -0,0 +1,55 @@
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from app.api.router import api_router
from app.core.config import settings
from app.core.database import init_db
from app.deps import NotAuthenticated
from app.web.admin import router as admin_router
from app.web.pages import router as pages_router
STATIC_DIR = Path(__file__).resolve().parent / "static"
@asynccontextmanager
async def lifespan(app: FastAPI):
# Dev/SQLite convenience. Use Alembic migrations in production.
if settings.auto_create_tables:
await init_db()
yield
def create_app() -> FastAPI:
app = FastAPI(title=settings.app_name, debug=settings.debug, lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.include_router(api_router, prefix="/api")
app.include_router(pages_router)
app.include_router(admin_router)
@app.exception_handler(NotAuthenticated)
async def _redirect_to_login(request: Request, exc: NotAuthenticated):
return RedirectResponse(url="/login", status_code=302)
@app.get("/healthz", tags=["health"])
async def healthz():
return {"status": "ok"}
return app
app = create_app()
+15
View File
@@ -0,0 +1,15 @@
"""Import all models so they register with ``Base.metadata``."""
from app.models.associations import role_permissions, user_roles
from app.models.permission import Permission
from app.models.refresh_token import RefreshToken
from app.models.role import Role
from app.models.user import User
__all__ = [
"Permission",
"RefreshToken",
"Role",
"User",
"role_permissions",
"user_roles",
]
+18
View File
@@ -0,0 +1,18 @@
"""Many-to-many association tables for RBAC."""
from sqlalchemy import Column, ForeignKey, Table
from app.core.database import Base
user_roles = Table(
"user_roles",
Base.metadata,
Column("user_id", ForeignKey("users.id", ondelete="CASCADE"), primary_key=True),
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
)
role_permissions = Table(
"role_permissions",
Base.metadata,
Column("role_id", ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True),
Column("permission_id", ForeignKey("permissions.id", ondelete="CASCADE"), primary_key=True),
)
+19
View File
@@ -0,0 +1,19 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import role_permissions
class Permission(Base):
__tablename__ = "permissions"
id: Mapped[int] = mapped_column(primary_key=True)
# Convention: "<resource>:<action>", e.g. "users:read".
name: Mapped[str] = mapped_column(String(128), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None)
roles: Mapped[list["Role"]] = relationship( # noqa: F821
secondary=role_permissions,
back_populates="permissions",
)
+29
View File
@@ -0,0 +1,29 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.core.database import Base
class RefreshToken(Base):
"""Server-side record of an issued refresh token, enabling revocation.
The raw token is never stored — only its jti (for lookup) and a SHA-256
hash (for verification). Rotation marks the old row revoked and inserts a
new one; logout revokes the row(s) for a user.
"""
__tablename__ = "refresh_tokens"
id: Mapped[int] = mapped_column(primary_key=True)
jti: Mapped[str] = mapped_column(String(64), unique=True, index=True)
token_hash: Mapped[str] = mapped_column(String(64))
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), index=True
)
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True))
revoked: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import role_permissions, user_roles
class Role(Base):
__tablename__ = "roles"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(64), unique=True, index=True)
description: Mapped[str | None] = mapped_column(String(255), default=None)
users: Mapped[list["User"]] = relationship( # noqa: F821
secondary=user_roles,
back_populates="roles",
)
permissions: Mapped[list["Permission"]] = relationship( # noqa: F821
secondary=role_permissions,
back_populates="roles",
lazy="selectin",
)
+35
View File
@@ -0,0 +1,35 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
from app.models.associations import user_roles
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
hashed_password: Mapped[str] = mapped_column(String(255))
full_name: Mapped[str | None] = mapped_column(String(255), default=None)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
is_superuser: Mapped[bool] = mapped_column(Boolean, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now()
)
roles: Mapped[list["Role"]] = relationship( # noqa: F821
secondary=user_roles,
back_populates="users",
lazy="selectin",
)
@property
def permissions(self) -> set[str]:
"""Flattened set of permission names granted via this user's roles."""
return {perm.name for role in self.roles for perm in role.permissions}
def has_permission(self, name: str) -> bool:
return self.is_superuser or name in self.permissions
View File
Whitespace-only changes.
+31
View File
@@ -0,0 +1,31 @@
from pydantic import BaseModel, ConfigDict
class PermissionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
name: str
description: str | None = None
class RoleBase(BaseModel):
name: str
description: str | None = None
class RoleCreate(RoleBase):
permission_ids: list[int] = []
class RoleUpdate(BaseModel):
name: str | None = None
description: str | None = None
permission_ids: list[int] | None = None
class RoleRead(RoleBase):
model_config = ConfigDict(from_attributes=True)
id: int
permissions: list[PermissionRead] = []
+17
View File
@@ -0,0 +1,17 @@
from pydantic import BaseModel
class Token(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenRefreshRequest(BaseModel):
refresh_token: str
class LogoutRequest(BaseModel):
# Optional: revoke a specific refresh token. If omitted, all of the
# current user's refresh tokens are revoked.
refresh_token: str | None = None
+39
View File
@@ -0,0 +1,39 @@
from datetime import datetime
from pydantic import BaseModel, ConfigDict, EmailStr
from app.schemas.rbac import RoleRead
class UserBase(BaseModel):
email: EmailStr
full_name: str | None = None
class UserCreate(UserBase):
password: str
is_superuser: bool = False
role_ids: list[int] = []
class UserUpdate(BaseModel):
email: EmailStr | None = None
full_name: str | None = None
password: str | None = None
is_active: bool | None = None
is_superuser: bool | None = None
role_ids: list[int] | None = None
class UserRead(UserBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
is_superuser: bool
created_at: datetime
roles: list[RoleRead] = []
class RegisterRequest(UserBase):
password: str
View File
Whitespace-only changes.
+65
View File
@@ -0,0 +1,65 @@
"""Token issuance / rotation shared by the JSON API and the HTMX UI."""
import jwt
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import (
REFRESH_TOKEN_TYPE,
create_access_token,
create_refresh_token,
decode_token,
)
from app.crud import token as token_crud
from app.models.user import User
async def issue_token_pair(db: AsyncSession, user: User) -> tuple[str, str]:
"""Create an access + refresh token and persist the refresh token for revocation."""
access = create_access_token(user.id)
refresh, payload = create_refresh_token(user.id)
await token_crud.store(
db,
user_id=user.id,
jti=payload["jti"],
raw_token=refresh,
expires_at=payload["exp"], # timezone-aware UTC datetime
)
return access, refresh
class RefreshError(Exception):
pass
async def rotate_refresh_token(db: AsyncSession, raw_refresh: str) -> tuple[str, str, User]:
"""Validate a refresh token, revoke it, and issue a fresh pair.
Raises ``RefreshError`` if the token is invalid, expired, or already revoked.
"""
try:
payload = decode_token(raw_refresh)
except jwt.PyJWTError as exc:
raise RefreshError("Invalid refresh token") from exc
if payload.get("type") != REFRESH_TOKEN_TYPE:
raise RefreshError("Not a refresh token")
jti = payload.get("jti")
sub = payload.get("sub")
if not jti or sub is None:
raise RefreshError("Malformed refresh token")
record = await token_crud.get_active(db, jti=jti, raw_token=raw_refresh)
if record is None:
raise RefreshError("Refresh token is revoked or expired")
# Local import avoids a circular import at module load time.
from app.crud import user as user_crud
user = await user_crud.get(db, int(sub))
if user is None or not user.is_active:
raise RefreshError("User is inactive or missing")
# Rotate: revoke the presented token, then mint a new pair.
await token_crud.revoke(db, record)
access, refresh = await issue_token_pair(db, user)
return access, refresh, user
+101
View File
@@ -0,0 +1,101 @@
:root {
--bg: #0f172a;
--surface: #1e293b;
--surface-2: #273449;
--border: #334155;
--text: #e2e8f0;
--muted: #94a3b8;
--primary: #6366f1;
--danger: #ef4444;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.5;
}
a { color: #a5b4fc; text-decoration: none; }
a:hover { text-decoration: underline; }
.muted { color: var(--muted); }
.container { max-width: 1000px; margin: 0 auto; padding: 1.5rem; }
/* Top bar */
.topbar {
display: flex; align-items: center; gap: 1.5rem;
padding: 0.75rem 1.5rem;
background: var(--surface);
border-bottom: 1px solid var(--border);
}
.topbar .brand a { font-weight: 700; color: var(--text); }
.topbar nav { display: flex; gap: 1rem; }
.topbar .logout { margin-left: auto; display: flex; align-items: center; gap: 0.75rem; }
/* Cards */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 1.25rem;
margin-bottom: 1.25rem;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.25rem; }
@media (max-width: 720px) { .grid { grid-template-columns: 1fr; } }
h1 { margin-top: 0; }
h2 { margin-top: 0; font-size: 1.1rem; }
/* Tables */
.table { width: 100%; border-collapse: collapse; }
.table th, .table td { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid var(--border); vertical-align: middle; }
.table th { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; }
.row-actions { display: flex; gap: 0.4rem; }
/* Tags / definition lists */
.tag {
display: inline-block; background: var(--surface-2); border: 1px solid var(--border);
border-radius: 999px; padding: 0.05rem 0.55rem; margin: 0 0.2rem 0.2rem 0; font-size: 0.8rem;
}
.kv { display: grid; grid-template-columns: auto 1fr; gap: 0.4rem 1rem; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; }
.links { padding-left: 1.1rem; }
/* Forms */
label { display: block; margin-bottom: 0.6rem; font-size: 0.9rem; }
input[type=text], input[type=email], input[type=password] {
display: block; width: 100%; margin-top: 0.25rem;
padding: 0.5rem; border-radius: 6px; border: 1px solid var(--border);
background: var(--bg); color: var(--text);
}
.check { display: flex; align-items: center; gap: 0.4rem; }
.check input { width: auto; margin: 0; }
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem 1rem; align-items: end; }
.form-grid > button { grid-column: 1 / -1; justify-self: start; }
.roles-field { grid-column: 1 / -1; border: 1px solid var(--border); border-radius: 6px; padding: 0.6rem; }
.roles-field legend { color: var(--muted); padding: 0 0.4rem; }
.inline-edit { display: flex; flex-wrap: wrap; gap: 0.75rem; align-items: end; }
/* Buttons */
.btn {
font: inherit; cursor: pointer; border: 1px solid var(--border);
background: var(--surface-2); color: var(--text);
padding: 0.4rem 0.8rem; border-radius: 6px;
}
.btn:hover { filter: brightness(1.1); }
.btn-primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.btn-danger { background: transparent; border-color: var(--danger); color: #fca5a5; }
.btn-ghost { background: transparent; }
/* Auth */
.auth-shell { min-height: 100vh; display: grid; place-items: center; padding: 1rem; }
.auth-card { width: 100%; max-width: 360px; }
.auth-card h1 { margin: 0 0 0.25rem; font-size: 1.4rem; }
.auth-card button { width: 100%; margin-top: 0.5rem; }
.alert { background: rgba(239, 68, 68, 0.15); border: 1px solid var(--danger); color: #fca5a5; padding: 0.5rem 0.75rem; border-radius: 6px; margin-bottom: 0.75rem; }
+17
View File
@@ -0,0 +1,17 @@
<header class="topbar">
<div class="brand"><a href="/">{{ app_name }}</a></div>
<nav>
<a href="/">Dashboard</a>
{% if user.is_superuser or "users:read" in user.permissions %}
<a href="/admin/users">Users</a>
{% endif %}
{% if user.is_superuser or "roles:read" in user.permissions %}
<a href="/admin/roles">Roles</a>
{% endif %}
<a href="/docs" target="_blank">API docs</a>
</nav>
<form method="post" action="/logout" class="logout">
<span class="muted">{{ user.email }}</span>
<button type="submit" class="btn btn-ghost">Sign out</button>
</form>
</header>
+24
View File
@@ -0,0 +1,24 @@
<div id="role-list" class="card">
<table class="table">
<thead>
<tr><th>ID</th><th>Name</th><th>Description</th><th>Permissions</th><th></th></tr>
</thead>
<tbody>
{% for r in roles %}
<tr id="role-{{ r.id }}">
<td>{{ r.id }}</td>
<td>{{ r.name }}</td>
<td>{{ r.description or "" }}</td>
<td>{% for perm in r.permissions %}<span class="tag">{{ perm.name }}</span>{% endfor %}</td>
<td class="row-actions">
<button class="btn btn-danger"
hx-delete="/admin/roles/{{ r.id }}"
hx-target="#role-{{ r.id }}"
hx-swap="outerHTML"
hx-confirm="Delete role {{ r.name }}?">Delete</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
+19
View File
@@ -0,0 +1,19 @@
<tr id="user-{{ u.id }}">
<td>{{ u.id }}</td>
<td>{{ u.email }}</td>
<td>{{ u.full_name or "" }}</td>
<td>{{ "✓" if u.is_active else "—" }}</td>
<td>{{ "✓" if u.is_superuser else "—" }}</td>
<td>{% for role in u.roles %}<span class="tag">{{ role.name }}</span>{% endfor %}</td>
<td class="row-actions">
<button class="btn btn-ghost"
hx-get="/admin/users/{{ u.id }}/edit"
hx-target="#user-{{ u.id }}"
hx-swap="outerHTML">Edit</button>
<button class="btn btn-danger"
hx-delete="/admin/users/{{ u.id }}"
hx-target="#user-{{ u.id }}"
hx-swap="outerHTML"
hx-confirm="Delete user {{ u.email }}?">Delete</button>
</td>
</tr>
+30
View File
@@ -0,0 +1,30 @@
<tr id="user-{{ u.id }}">
<td colspan="7">
<form hx-post="/admin/users/{{ u.id }}"
hx-target="#user-{{ u.id }}"
hx-swap="outerHTML"
class="inline-edit">
<span class="muted">#{{ u.id }} · {{ u.email }}</span>
<label>Name <input type="text" name="full_name" value="{{ u.full_name or '' }}"></label>
<label>New password <input type="password" name="password" placeholder="(unchanged)"></label>
<label class="check"><input type="checkbox" name="is_active" value="true" {{ "checked" if u.is_active }}> Active</label>
<label class="check"><input type="checkbox" name="is_superuser" value="true" {{ "checked" if u.is_superuser }}> Superuser</label>
<fieldset class="roles-field">
<legend>Roles</legend>
{% for role in roles %}
<label class="check">
<input type="checkbox" name="role_ids" value="{{ role.id }}"
{{ "checked" if role in u.roles }}> {{ role.name }}
</label>
{% endfor %}
</fieldset>
<div class="row-actions">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-ghost"
hx-get="/admin/users/{{ u.id }}/row"
hx-target="#user-{{ u.id }}"
hx-swap="outerHTML">Cancel</button>
</div>
</form>
</td>
</tr>
+14
View File
@@ -0,0 +1,14 @@
<div id="user-list" class="card">
<table class="table">
<thead>
<tr>
<th>ID</th><th>Email</th><th>Name</th><th>Active</th><th>Superuser</th><th>Roles</th><th></th>
</tr>
</thead>
<tbody>
{% for u in users %}
{% include "admin/_user_row.html" %}
{% endfor %}
</tbody>
</table>
</div>
+27
View File
@@ -0,0 +1,27 @@
{% extends "base.html" %}
{% block title %}Roles · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Roles &amp; permissions</h1>
<div class="card">
<h2>Create role</h2>
<form hx-post="/admin/roles" hx-target="#role-list" hx-swap="outerHTML" class="form-grid">
<label>Name <input type="text" name="name" required></label>
<label>Description <input type="text" name="description"></label>
<fieldset class="roles-field">
<legend>Permissions</legend>
{% for perm in permissions %}
<label class="check"><input type="checkbox" name="permission_ids" value="{{ perm.id }}"> {{ perm.name }}</label>
{% else %}
<span class="muted">No permissions defined. Run the seed script.</span>
{% endfor %}
</fieldset>
<button type="submit" class="btn btn-primary">Add role</button>
</form>
</div>
{% include "admin/_roles_table.html" %}
</main>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Users · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Users</h1>
<div class="card">
<h2>Create user</h2>
<form hx-post="/admin/users" hx-target="#user-list" hx-swap="outerHTML" class="form-grid">
<label>Email <input type="email" name="email" required></label>
<label>Full name <input type="text" name="full_name"></label>
<label>Password <input type="password" name="password" required></label>
<label class="check">
<input type="checkbox" name="is_superuser" value="true"> Superuser
</label>
<fieldset class="roles-field">
<legend>Roles</legend>
{% for role in roles %}
<label class="check"><input type="checkbox" name="role_ids" value="{{ role.id }}"> {{ role.name }}</label>
{% endfor %}
</fieldset>
<button type="submit" class="btn btn-primary">Add user</button>
</form>
</div>
{% include "admin/_users_table.html" %}
</main>
{% endblock %}
+15
View File
@@ -0,0 +1,15 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="/static/css/style.css">
<script src="https://unpkg.com/htmx.org@1.9.12"
integrity="sha384-ujb1lZYygJmzgSwoxRggbCHcjc0rB2XoQrxeTUQyRjrOnlCoYta87iKBWq3EsdM2"
crossorigin="anonymous"></script>
</head>
<body>
{% block body %}{% endblock %}
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}Dashboard · {{ app_name }}{% endblock %}
{% block body %}
{% include "_nav.html" %}
<main class="container">
<h1>Dashboard</h1>
<p class="muted">Welcome back, {{ user.full_name or user.email }}.</p>
<div class="grid">
<div class="card">
<h2>Your account</h2>
<dl class="kv">
<dt>Email</dt><dd>{{ user.email }}</dd>
<dt>Superuser</dt><dd>{{ "Yes" if user.is_superuser else "No" }}</dd>
<dt>Roles</dt>
<dd>
{% for role in user.roles %}<span class="tag">{{ role.name }}</span>{% else %}<span class="muted">none</span>{% endfor %}
</dd>
<dt>Permissions</dt>
<dd>
{% if user.is_superuser %}<span class="tag">* (all)</span>
{% else %}
{% for perm in user.permissions|sort %}<span class="tag">{{ perm }}</span>{% else %}<span class="muted">none</span>{% endfor %}
{% endif %}
</dd>
</dl>
</div>
<div class="card">
<h2>Quick links</h2>
<ul class="links">
<li><a href="/admin/users">Manage users</a></li>
<li><a href="/admin/roles">Manage roles &amp; permissions</a></li>
<li><a href="/docs" target="_blank">Interactive API docs (Swagger)</a></li>
<li><a href="/redoc" target="_blank">API reference (ReDoc)</a></li>
</ul>
</div>
</div>
</main>
{% endblock %}
+18
View File
@@ -0,0 +1,18 @@
{% extends "base.html" %}
{% block title %}Sign in · {{ app_name }}{% endblock %}
{% block body %}
<div class="auth-shell">
<form class="card auth-card" method="post" action="/login">
<h1>{{ app_name }}</h1>
<p class="muted">Sign in to continue</p>
{% if error %}<div class="alert">{{ error }}</div>{% endif %}
<label>Email
<input type="email" name="email" value="{{ email or '' }}" required autofocus>
</label>
<label>Password
<input type="password" name="password" required>
</label>
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
</div>
{% endblock %}
View File
Whitespace-only changes.
+204
View File
@@ -0,0 +1,204 @@
"""HTMX-driven admin dashboard for users and roles."""
from fastapi import APIRouter, Depends, Form, HTTPException, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.crud import role as role_crud
from app.crud import user as user_crud
from app.deps import get_current_web_user
from app.models.user import User
from app.web.templates import templates
router = APIRouter(prefix="/admin", include_in_schema=False)
def web_require(*perms: str):
"""Web dependency: allow superusers or holders of all given permissions."""
async def checker(user: User = Depends(get_current_web_user)) -> User:
if user.is_superuser or set(perms).issubset(user.permissions):
return user
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="You do not have access to this page.",
)
return checker
@router.get("", response_class=HTMLResponse)
async def admin_home():
return RedirectResponse(url="/admin/users", status_code=status.HTTP_302_FOUND)
# --- Users -------------------------------------------------------------------
@router.get("/users", response_class=HTMLResponse)
async def users_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("users:read")),
):
return templates.TemplateResponse(
request,
"admin/users.html",
{
"user": user,
"users": await user_crud.list_users(db),
"roles": await role_crud.list_roles(db),
},
)
@router.post("/users", response_class=HTMLResponse)
async def create_user(
request: Request,
email: str = Form(...),
password: str = Form(...),
full_name: str = Form(""),
is_superuser: bool = Form(False),
role_ids: list[int] = Form(default=[]),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:write")),
):
if not await user_crud.get_by_email(db, email):
await user_crud.create(
db,
email=email,
password=password,
full_name=full_name or None,
is_superuser=is_superuser,
role_ids=role_ids,
)
return templates.TemplateResponse(
request,
"admin/_users_table.html",
{"users": await user_crud.list_users(db), "roles": await role_crud.list_roles(db)},
)
@router.get("/users/{user_id}/edit", response_class=HTMLResponse)
async def edit_user_row(
request: Request,
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:write")),
):
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
return templates.TemplateResponse(
request,
"admin/_user_row_edit.html",
{"u": target, "roles": await role_crud.list_roles(db)},
)
@router.get("/users/{user_id}/row", response_class=HTMLResponse)
async def user_row(
request: Request,
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:read")),
):
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
return templates.TemplateResponse(request, "admin/_user_row.html", {"u": target})
@router.post("/users/{user_id}", response_class=HTMLResponse)
async def update_user(
request: Request,
user_id: int,
full_name: str = Form(""),
password: str = Form(""),
is_active: bool = Form(False),
is_superuser: bool = Form(False),
role_ids: list[int] = Form(default=[]),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:write")),
):
target = await user_crud.get(db, user_id)
if target is None:
raise HTTPException(status_code=404, detail="User not found")
await user_crud.update(
db,
target,
{
"full_name": full_name or None,
"password": password or None,
"is_active": is_active,
"is_superuser": is_superuser,
"role_ids": role_ids,
},
)
return templates.TemplateResponse(request, "admin/_user_row.html", {"u": target})
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("users:delete")),
):
target = await user_crud.get(db, user_id)
if target is not None:
await user_crud.delete(db, target)
# Empty body — HTMX swaps the row out of existence.
return Response(status_code=status.HTTP_200_OK)
# --- Roles -------------------------------------------------------------------
@router.get("/roles", response_class=HTMLResponse)
async def roles_page(
request: Request,
db: AsyncSession = Depends(get_db),
user: User = Depends(web_require("roles:read")),
):
return templates.TemplateResponse(
request,
"admin/roles.html",
{
"user": user,
"roles": await role_crud.list_roles(db),
"permissions": await role_crud.list_permissions(db),
},
)
@router.post("/roles", response_class=HTMLResponse)
async def create_role(
request: Request,
name: str = Form(...),
description: str = Form(""),
permission_ids: list[int] = Form(default=[]),
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:write")),
):
if not await role_crud.get_by_name(db, name):
await role_crud.create(
db,
name=name,
description=description or None,
permission_ids=permission_ids,
)
return templates.TemplateResponse(
request,
"admin/_roles_table.html",
{"roles": await role_crud.list_roles(db)},
)
@router.delete("/roles/{role_id}")
async def delete_role(
role_id: int,
db: AsyncSession = Depends(get_db),
_: User = Depends(web_require("roles:write")),
):
role = await role_crud.get(db, role_id)
if role is not None:
await role_crud.delete(db, role)
return Response(status_code=status.HTTP_200_OK)
+69
View File
@@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, Form, Request, status
from fastapi.responses import HTMLResponse, RedirectResponse, Response
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.database import get_db
from app.crud import user as user_crud
from app.deps import get_current_web_user
from app.models.user import User
from app.services.auth import issue_token_pair
from app.web.templates import templates
router = APIRouter(include_in_schema=False)
def _set_auth_cookie(response: Response, access_token: str) -> None:
response.set_cookie(
key=settings.cookie_name,
value=access_token,
httponly=True,
secure=settings.cookie_secure,
samesite="lax",
max_age=settings.access_token_expire_minutes * 60,
path="/",
)
@router.get("/", response_class=HTMLResponse)
async def dashboard(
request: Request,
user: User = Depends(get_current_web_user),
):
return templates.TemplateResponse(
request, "dashboard.html", {"user": user}
)
@router.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
return templates.TemplateResponse(request, "login.html", {})
@router.post("/login")
async def login_submit(
request: Request,
email: str = Form(...),
password: str = Form(...),
db: AsyncSession = Depends(get_db),
):
user = await user_crud.authenticate(db, email, password)
if user is None:
# Re-render the form with an error (HTMX swaps the form partial).
return templates.TemplateResponse(
request,
"login.html",
{"error": "Incorrect email or password", "email": email},
status_code=status.HTTP_401_UNAUTHORIZED,
)
access, _ = await issue_token_pair(db, user)
response = RedirectResponse(url="/", status_code=status.HTTP_303_SEE_OTHER)
_set_auth_cookie(response, access)
return response
@router.post("/logout")
async def logout(request: Request):
response = RedirectResponse(url="/login", status_code=status.HTTP_303_SEE_OTHER)
response.delete_cookie(settings.cookie_name, path="/")
return response
+10
View File
@@ -0,0 +1,10 @@
from pathlib import Path
from fastapi.templating import Jinja2Templates
from app.core.config import settings
TEMPLATES_DIR = Path(__file__).resolve().parent.parent / "templates"
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
templates.env.globals["app_name"] = settings.app_name
+37
View File
@@ -0,0 +1,37 @@
services:
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: app
POSTGRES_PASSWORD: app
POSTGRES_DB: app
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U app"]
interval: 5s
timeout: 5s
retries: 5
app:
build: .
environment:
DATABASE_URL: postgresql+asyncpg://app:app@db:5432/app
SECRET_KEY: dev-secret-change-me-in-production
AUTO_CREATE_TABLES: "true"
FIRST_SUPERUSER_EMAIL: admin@example.com
FIRST_SUPERUSER_PASSWORD: changethis
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
# Seed default roles/permissions/superuser, then start the server.
command: >
sh -c "uv run python -m scripts.seed &&
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000"
volumes:
pgdata:
+44
View File
@@ -0,0 +1,44 @@
[project]
name = "fastapi-skeleton"
version = "0.1.0"
description = "FastAPI + HTMX skeleton with async SQLAlchemy, RBAC, JWT auth, and an admin dashboard."
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"fastapi>=0.111",
"uvicorn[standard]>=0.30",
"jinja2>=3.1",
"python-multipart>=0.0.9",
"sqlalchemy[asyncio]>=2.0.30",
"alembic>=1.13",
"aiosqlite>=0.20",
"asyncpg>=0.29",
"pydantic[email]>=2.7",
"pydantic-settings>=2.3",
"pyjwt>=2.8",
"bcrypt>=4.1",
]
[project.optional-dependencies]
dev = [
"pytest>=8.2",
"pytest-asyncio>=0.23",
"httpx>=0.27",
"ruff>=0.5",
]
[tool.uv]
package = false
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
line-length = 100
target-version = "py311"
[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
# B008: FastAPI relies on calling Depends()/Form()/Query() in argument defaults.
ignore = ["B008"]
View File
Whitespace-only changes.
+76
View File
@@ -0,0 +1,76 @@
"""Bootstrap default permissions, roles, and the first superuser.
Idempotent: safe to run repeatedly. Reads the superuser credentials from
settings (env vars or .env).
Usage:
uv run python -m scripts.seed
"""
import asyncio
from app.core.config import settings
from app.core.database import AsyncSessionLocal, init_db
from app.crud import role as role_crud
from app.crud import user as user_crud
# Default permission catalogue: "<resource>:<action>".
PERMISSIONS = [
("users:read", "View users"),
("users:write", "Create and update users"),
("users:delete", "Delete users"),
("roles:read", "View roles and permissions"),
("roles:write", "Create, update and delete roles"),
]
# Default roles -> permission names. "admin" gets everything.
ROLES = {
"admin": [name for name, _ in PERMISSIONS],
"user": ["users:read"],
}
async def seed() -> None:
await init_db()
async with AsyncSessionLocal() as db:
# Permissions
perms = {}
for name, description in PERMISSIONS:
perm = await role_crud.get_permission_by_name(db, name)
if perm is None:
perm = await role_crud.create_permission(db, name=name, description=description)
perms[name] = perm
# Roles
roles = {}
for role_name, perm_names in ROLES.items():
role = await role_crud.get_by_name(db, role_name)
if role is None:
role = await role_crud.create(
db,
name=role_name,
description=f"{role_name.title()} role",
permission_ids=[perms[p].id for p in perm_names],
)
roles[role_name] = role
# Superuser
existing = await user_crud.get_by_email(db, settings.first_superuser_email)
if existing is None:
await user_crud.create(
db,
email=settings.first_superuser_email,
password=settings.first_superuser_password,
full_name="Administrator",
is_superuser=True,
role_ids=[roles["admin"].id],
)
print(f"Created superuser: {settings.first_superuser_email}")
else:
print(f"Superuser already exists: {settings.first_superuser_email}")
print("Seed complete.")
if __name__ == "__main__":
asyncio.run(seed())
+145
View File
@@ -0,0 +1,145 @@
❯ Please create a skeleton project for a typical web application that serves HTML and JSON data. The skeleton
should build out a basic scaffold using python FastAPI, HTMX, and an ORM that can use sqlite or postgres. The
skeleton should also support an admin dashboard, RBAC, and JWT token management interfaces for users. Since this
is a skeleton configuration should be accepted either by file or by environment variables. There should be a
Docker file as well. Please ask any clarifying questions and propose a plan for this project.
● I'll ask a few key questions to shape the skeleton, since several decisions here meaningfully change the
structure.
● User answered Claude's questions:
⎿  · Which ORM / data layer approach do you want? → SQLAlchemy 2.0 async (Recommended)
· How should the admin dashboard be built? → Custom HTMX dashboard (Recommended)
· What auth/JWT depth do you want in the skeleton? → Access + refresh + revocation (Recommended)
· Python tooling / dependency manager? → uv (Recommended)
● Good — all defaults confirmed. Let me present a concrete plan for approval before building.
● Here's my proposed plan for the FastAPI skeleton.
Stack (confirmed)
- FastAPI + Jinja2 + HTMX (server-rendered HTML, JSON API alongside)
- SQLAlchemy 2.0 async + Alembic migrations; SQLite (aiosqlite) or Postgres (asyncpg) via one DATABASE_URL
- JWT: access + refresh tokens with rotation and DB-backed revocation (logout / blocklist)
- RBAC: users ↔ roles ↔ permissions, enforced via FastAPI dependencies
- Custom HTMX admin dashboard (user/role CRUD)
- uv for deps; Docker + docker-compose (app + Postgres)
- Config via pydantic-settings — reads env vars and .env file
Project layout
fastapi_skeleton/
├── app/
│ ├── main.py # app factory, router + middleware wiring, lifespan
│ ├── core/
│ │ ├── config.py # pydantic-settings (env + .env)
│ │ ├── security.py # password hashing, JWT encode/decode
│ │ └── database.py # async engine, session, Base
│ ├── models/ # user, role, permission, refresh_token (revocation)
│ ├── schemas/ # pydantic request/response models
│ ├── crud/ # data-access helpers
│ ├── deps.py # get_db, get_current_user, require_permission(...)
│ ├── api/ # JSON API
│ │ ├── auth.py # register, login, refresh, logout
│ │ ├── users.py # me, user management
│ │ └── router.py
│ ├── web/ # HTML/HTMX routes
│ │ ├── pages.py # login page, dashboard home
│ │ └── admin.py # admin: users, roles, permissions CRUD (HTMX)
│ ├── templates/ # Jinja2: base, partials, admin/*
│ └── static/ # htmx.min.js, minimal css
├── alembic/ + alembic.ini # migrations
├── scripts/seed.py # bootstrap admin user + default roles/perms
├── tests/ # pytest: auth, rbac, smoke
├── pyproject.toml + uv.lock
├── Dockerfile # multi-stage, uv-based
├── docker-compose.yml # app + postgres
├── .env.example
├── .dockerignore / .gitignore
└── README.md
Key design points
- RBAC model: require_permission("users:write") dependency factory; roles aggregate permissions; a superuser flag
bypasses checks. Seed script creates admin/user roles + an initial admin.
- Auth flow: /api/auth/login → access (short) + refresh (long) tokens; /api/auth/refresh rotates and revokes the
old refresh token; /api/auth/logout revokes. Refresh tokens stored hashed in DB with a revoked flag.
- Web vs API auth: API uses Authorization: Bearer; the HTMX UI uses an httpOnly cookie holding the access token so
the dashboard works without JS token handling.
- DB portability: single DATABASE_URL; defaults to SQLite file so it runs with zero setup, Postgres via compose.
- Config: all settings (secret key, token TTLs, DB URL, CORS, admin bootstrap creds) overridable by env var or
.env.
- Tests: pytest + httpx async client against an in-memory SQLite, covering register/login/refresh/logout and an
RBAC permission check.
Build order
1. Tooling + config + Docker scaffolding
2. Database, models, Alembic
3. Security + auth API + revocation
4. RBAC deps + user/role API
5. HTMX templates + admin dashboard
6. Seed script, tests, README
This is a sizable scaffold (~35–40 files). Want me to build it all now, or would you prefer I build it phase by
phase so you can review as I go? I'll start with phase 1 either way unless you'd like to adjust anything above.
● Everything works. Let me verify the hand-written Alembic migration applies cleanly against a fresh database:
● Bash(cd /home/iroddis/dev/fastapi_skeleton
export DATABASE_URL="sqlite+aiosqlite:///./alembic_test.db" SECRET_KEY=test…)
⎿  == upgrade head ==
INFO [alembic.runtime.migration] Context impl SQLiteImpl.
INFO [alembic.runtime.migration] Will assume non-transactional DDL.
… +7 lines (ctrl+o to expand)
● Migrations work both directions. Let me stop the server and clean up:
● Bash(rm -f app.db alembic_test.db /tmp/skel_cookies.txt /tmp/skeleton_server.log; echo "cleaned")
⎿  cleaned
● Stop Task
⎿  SECRET_KEY=test-secret AUTO_CREATE_TABLES=true uv run uvicorn app.main:app --port 8099 · stopped
● Verified and working. Here's what I confirmed:
Results
Static checks
- ruff check . → All checks passed (B008 ignored — that rule fires on FastAPI's Depends()-in-defaults idiom, which
is intentional)
- uv sync --extra dev resolved and installed cleanly on Python 3.14
Test suite — uv run pytest → 7 passed
- Auth lifecycle: register → login → /me → refresh (with rotation) → confirms old refresh token is revoked →
logout invalidates the rotated token
- Wrong password rejected; unauthenticated /me returns 401
- RBAC: reader role can GET but not POST users (403); superuser bypasses checks; user with no roles is forbidden;
registration grants the default user role
Live smoke test (seeded SQLite, real uvicorn server)
- Login page renders; unauthenticated / → 302 redirect to /login
- API bearer-token login works; /api/users/me returns the admin with all permissions; /api/roles → 200
- Web cookie flow: form login → 303 + httpOnly access_token cookie → dashboard and /admin/users render → HTMX
create-user returns the table partial with the new row
- RBAC denial confirmed live: a normal registered user gets 403 on POST /api/users but 200 on GET /api/users
Alembic — the hand-written 0001_initial migration applies (upgrade head creates all 7 tables) and reverses cleanly
(downgrade base).
One fix made during verification
app/services/auth.py was passing datetime.fromtimestamp(payload["exp"]), but PyJWT leaves exp as a timezone-aware
datetime in the payload dict (it only converts to epoch inside the encoded token). Changed it to use
payload["exp"] directly — this was the cause of the initial 5 failures, now all green.
The skeleton is complete and functional. To run it yourself:
uv sync --extra dev
uv run python -m scripts.seed # admin@example.com / changethis
uv run uvicorn app.main:app --reload # http://localhost:8000
Test artifacts (app.db, cookie jar, logs) were cleaned up, and the background server was stopped.
View File
Whitespace-only changes.
+65
View File
@@ -0,0 +1,65 @@
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.core.database import Base, get_db
from app.crud import role as role_crud
from app.crud import user as user_crud
from app.main import app
from scripts.seed import PERMISSIONS, ROLES
@pytest_asyncio.fixture
async def db_sessionmaker():
# In-memory SQLite shared across connections for the test's lifetime.
engine = create_async_engine(
"sqlite+aiosqlite://",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
maker = async_sessionmaker(engine, expire_on_commit=False)
yield maker
await engine.dispose()
@pytest_asyncio.fixture
async def seeded(db_sessionmaker):
"""Seed permissions + roles and return ids needed by tests."""
async with db_sessionmaker() as db:
perms = {}
for name, desc in PERMISSIONS:
perms[name] = await role_crud.create_permission(db, name=name, description=desc)
roles = {}
for role_name, perm_names in ROLES.items():
roles[role_name] = await role_crud.create(
db,
name=role_name,
permission_ids=[perms[p].id for p in perm_names],
)
return {"roles": roles, "perms": perms}
@pytest_asyncio.fixture
async def client(db_sessionmaker):
async def override_get_db():
async with db_sessionmaker() as session:
yield session
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as c:
yield c
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def make_user(db_sessionmaker):
async def _make(email, password="secret123", **kwargs):
async with db_sessionmaker() as db:
return await user_crud.create(db, email=email, password=password, **kwargs)
return _make
+63
View File
@@ -0,0 +1,63 @@
async def test_register_login_me_refresh_logout(client, seeded):
# Register
resp = await client.post(
"/api/auth/register",
json={"email": "alice@example.com", "password": "secret123", "full_name": "Alice"},
)
assert resp.status_code == 201, resp.text
assert resp.json()["email"] == "alice@example.com"
# Login (OAuth2 form: username == email)
resp = await client.post(
"/api/auth/login",
data={"username": "alice@example.com", "password": "secret123"},
)
assert resp.status_code == 200, resp.text
tokens = resp.json()
access, refresh = tokens["access_token"], tokens["refresh_token"]
# /me with bearer token
resp = await client.get("/api/users/me", headers={"Authorization": f"Bearer {access}"})
assert resp.status_code == 200
assert resp.json()["email"] == "alice@example.com"
# Refresh rotates tokens
resp = await client.post("/api/auth/refresh", json={"refresh_token": refresh})
assert resp.status_code == 200, resp.text
new_refresh = resp.json()["refresh_token"]
assert new_refresh != refresh
# Old refresh token is now revoked
resp = await client.post("/api/auth/refresh", json={"refresh_token": refresh})
assert resp.status_code == 401
# Logout (revokes all) then the rotated refresh token is dead too
relogin = await client.post(
"/api/auth/login",
data={"username": "alice@example.com", "password": "secret123"},
)
new_access = relogin.json()["access_token"]
resp = await client.post(
"/api/auth/logout",
json={},
headers={"Authorization": f"Bearer {new_access}"},
)
assert resp.status_code == 204
resp = await client.post("/api/auth/refresh", json={"refresh_token": new_refresh})
assert resp.status_code == 401
async def test_login_wrong_password(client, seeded):
await client.post(
"/api/auth/register",
json={"email": "bob@example.com", "password": "secret123"},
)
resp = await client.post(
"/api/auth/login",
data={"username": "bob@example.com", "password": "wrong"},
)
assert resp.status_code == 401
async def test_me_requires_auth(client):
assert (await client.get("/api/users/me")).status_code == 401
+53
View File
@@ -0,0 +1,53 @@
async def _token(client, email, password="secret123"):
resp = await client.post(
"/api/auth/login", data={"username": email, "password": password}
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
async def test_reader_can_list_but_not_create(client, seeded, make_user):
# User with only the "user" role (grants users:read).
await make_user("reader@example.com", role_ids=[seeded["roles"]["user"].id])
token = await _token(client, "reader@example.com")
headers = {"Authorization": f"Bearer {token}"}
assert (await client.get("/api/users", headers=headers)).status_code == 200
resp = await client.post(
"/api/users",
json={"email": "new@example.com", "password": "secret123"},
headers=headers,
)
assert resp.status_code == 403
async def test_superuser_bypasses_permissions(client, seeded, make_user):
await make_user("root@example.com", is_superuser=True)
headers = {"Authorization": f"Bearer {await _token(client, 'root@example.com')}"}
resp = await client.post(
"/api/users",
json={"email": "created@example.com", "password": "secret123"},
headers=headers,
)
assert resp.status_code == 201, resp.text
# Superuser can also manage roles.
assert (await client.get("/api/roles", headers=headers)).status_code == 200
async def test_no_role_user_is_forbidden(client, seeded, make_user):
await make_user("plain@example.com") # no roles, not superuser
headers = {"Authorization": f"Bearer {await _token(client, 'plain@example.com')}"}
assert (await client.get("/api/users", headers=headers)).status_code == 403
async def test_registered_user_gets_default_role(client, seeded):
await client.post(
"/api/auth/register",
json={"email": "fresh@example.com", "password": "secret123"},
)
headers = {"Authorization": f"Bearer {await _token(client, 'fresh@example.com')}"}
me = (await client.get("/api/users/me", headers=headers)).json()
assert [r["name"] for r in me["roles"]] == ["user"]
Generated
+1175
View File
File diff suppressed because it is too large. Load diff