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