Initial Claude implementation
This commit is contained in:
commit
e2d3ab9c2a
63 files changed
+3930
No files matched your search
@@ -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
|
||||||
@@ -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
@@ -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/
|
||||||
@@ -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
@@ -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"]
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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())
|
||||||
@@ -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"}
|
||||||
@@ -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")
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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"])
|
||||||
@@ -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)
|
||||||
Whitespace-only changes.
@@ -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()
|
||||||
@@ -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)
|
||||||
@@ -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()
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
@@ -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
@@ -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()
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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()
|
||||||
|
)
|
||||||
@@ -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",
|
||||||
|
)
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
@@ -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] = []
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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; }
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block title %}Roles · {{ app_name }}{% endblock %}
|
||||||
|
{% block body %}
|
||||||
|
{% include "_nav.html" %}
|
||||||
|
<main class="container">
|
||||||
|
<h1>Roles & 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 %}
|
||||||
@@ -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 %}
|
||||||
@@ -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>
|
||||||
@@ -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 & 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 %}
|
||||||
@@ -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 %}
|
||||||
Whitespace-only changes.
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
@@ -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"]
|
||||||
Whitespace-only changes.
@@ -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())
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
Whitespace-only changes.
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
Reference in new issue
Block a user