Add admin account with user management endpoints

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 20:45:19 -07:00
parent 7994cc5ff2
commit 48a15c54f6
10 changed files with 238 additions and 5 deletions

View File

@@ -8,3 +8,7 @@ MYSQL_PASSWORD=changeme_db
DATABASE_URL=mysql+aiomysql://bourbonacci:changeme_db@db:3306/bourbonacci DATABASE_URL=mysql+aiomysql://bourbonacci:changeme_db@db:3306/bourbonacci
SECRET_KEY=changeme_generate_a_long_random_string_here SECRET_KEY=changeme_generate_a_long_random_string_here
ACCESS_TOKEN_EXPIRE_MINUTES=480 ACCESS_TOKEN_EXPIRE_MINUTES=480
# Admin account (seeded on every container start)
ADMIN_USERNAME=admin@example.com
ADMIN_PASSWORD=changeme_admin

View File

@@ -82,6 +82,18 @@ bourbonacci/
|---|---|---|---| |---|---|---|---|
| GET | `/api/public/stats` | No | Aggregated stats for all users | | GET | `/api/public/stats` | No | Aggregated stats for all users |
### Admin
| Method | Path | Auth | Description |
|---|---|---|---|
| GET | `/api/admin/users` | Admin | List all users |
| POST | `/api/admin/users` | Admin | Create a user |
| POST | `/api/admin/users/{id}/reset-password` | Admin | Force-reset a user's password |
| POST | `/api/admin/users/{id}/disable` | Admin | Disable a user account |
| POST | `/api/admin/users/{id}/enable` | Admin | Re-enable a user account |
| DELETE | `/api/admin/users/{id}` | Admin | Hard-delete a user |
| POST | `/api/admin/users/{id}/impersonate` | Admin | Get a token scoped as that user |
| POST | `/api/admin/unimpersonate` | Admin | Swap back to the admin token |
Authenticated routes expect `Authorization: Bearer <token>` header. Authenticated routes expect `Authorization: Bearer <token>` header.
### Entry Schema ### Entry Schema
@@ -146,6 +158,8 @@ docker compose down -v
| `DATABASE_URL` | SQLAlchemy async DSN | `mysql+aiomysql://...` | | `DATABASE_URL` | SQLAlchemy async DSN | `mysql+aiomysql://...` |
| `SECRET_KEY` | JWT signing secret (keep long & random) | — | | `SECRET_KEY` | JWT signing secret (keep long & random) | — |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT TTL in minutes | `480` | | `ACCESS_TOKEN_EXPIRE_MINUTES` | JWT TTL in minutes | `480` |
| `ADMIN_USERNAME` | Admin account email (seeded on every start) | — |
| `ADMIN_PASSWORD` | Admin account password (re-synced on every start) | — |
## Data Model ## Data Model

View File

@@ -6,6 +6,8 @@ class Settings(BaseSettings):
secret_key: str secret_key: str
access_token_expire_minutes: int = 480 access_token_expire_minutes: int = 480
algorithm: str = "HS256" algorithm: str = "HS256"
admin_username: str
admin_password: str
class Config: class Config:
env_file = ".env" env_file = ".env"

View File

@@ -31,5 +31,13 @@ async def get_current_user(
user = result.scalar_one_or_none() user = result.scalar_one_or_none()
if user is None: if user is None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found") raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found")
if user.is_disabled:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account disabled")
return user return user
async def get_current_admin(current_user=Depends(get_current_user)):
if not current_user.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin access required")
return current_user

View File

@@ -2,14 +2,37 @@ from contextlib import asynccontextmanager
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy import select
from app.database import init_db from app.config import settings
from app.routers import auth, users, entries, public from app.database import init_db, AsyncSessionLocal
from app.routers import auth, users, entries, public, admin
async def _seed_admin() -> None:
from app.models.user import User
from app.utils.security import hash_password
async with AsyncSessionLocal() as db:
result = await db.execute(select(User).where(User.email == settings.admin_username))
user = result.scalar_one_or_none()
if user is None:
db.add(User(
email=settings.admin_username,
password_hash=hash_password(settings.admin_password),
display_name="Admin",
is_admin=True,
))
else:
user.password_hash = hash_password(settings.admin_password)
user.is_admin = True
await db.commit()
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
await init_db() await init_db()
await _seed_admin()
yield yield
@@ -26,3 +49,4 @@ app.include_router(auth.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(entries.router) app.include_router(entries.router)
app.include_router(public.router) app.include_router(public.router)
app.include_router(admin.router)

View File

@@ -1,6 +1,6 @@
from datetime import datetime from datetime import datetime
from typing import Optional from typing import Optional
from sqlalchemy import String, DateTime, func from sqlalchemy import String, DateTime, Boolean, func
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.database import Base from app.database import Base
@@ -14,6 +14,8 @@ class User(Base):
password_hash: Mapped[str] = mapped_column(String(255), nullable=False) password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
display_name: Mapped[Optional[str]] = mapped_column(String(100)) display_name: Mapped[Optional[str]] = mapped_column(String(100))
timezone: Mapped[str] = mapped_column(String(50), default="UTC") timezone: Mapped[str] = mapped_column(String(50), default="UTC")
is_admin: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
is_disabled: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now()) created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
entries: Mapped[list["Entry"]] = relationship("Entry", back_populates="user", cascade="all, delete-orphan") entries: Mapped[list["Entry"]] = relationship("Entry", back_populates="user", cascade="all, delete-orphan")

View File

@@ -0,0 +1,146 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.dependencies import get_db, get_current_admin, bearer_scheme
from app.models.user import User
from app.schemas.user import AdminUserCreate, AdminPasswordReset, AdminUserResponse, Token
from app.utils.security import hash_password, create_token
router = APIRouter(prefix="/api/admin", tags=["admin"])
@router.get("/users", response_model=list[AdminUserResponse])
async def list_users(
_: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).order_by(User.created_at))
return result.scalars().all()
@router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED)
async def create_user(
body: AdminUserCreate,
_: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.email == body.email))
if result.scalar_one_or_none():
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Email already registered")
user = User(
email=body.email,
password_hash=hash_password(body.password),
display_name=body.display_name or body.email.split("@")[0],
is_admin=False,
)
async with db.begin():
db.add(user)
await db.refresh(user)
return user
@router.post("/users/{user_id}/reset-password", status_code=status.HTTP_204_NO_CONTENT)
async def reset_password(
user_id: int,
body: AdminPasswordReset,
_: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
async with db.begin():
user.password_hash = hash_password(body.new_password)
@router.post("/users/{user_id}/disable", status_code=status.HTTP_204_NO_CONTENT)
async def disable_user(
user_id: int,
current_admin: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
if user_id == current_admin.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot disable your own account")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
async with db.begin():
user.is_disabled = True
@router.post("/users/{user_id}/enable", status_code=status.HTTP_204_NO_CONTENT)
async def enable_user(
user_id: int,
_: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
async with db.begin():
user.is_disabled = False
@router.delete("/users/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_user(
user_id: int,
current_admin: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
if user_id == current_admin.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot delete your own account")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
async with db.begin():
await db.delete(user)
@router.post("/users/{user_id}/impersonate", response_model=Token)
async def impersonate_user(
user_id: int,
current_admin: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
if user_id == current_admin.id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Cannot impersonate yourself")
result = await db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
return Token(access_token=create_token(user.id, admin_id=current_admin.id))
@router.post("/unimpersonate", response_model=Token)
async def unimpersonate(
credentials=Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
):
from app.utils.security import decode_token_full
token = credentials.credentials
payload = decode_token_full(token)
admin_id = payload.get("admin_id") if payload else None
if not admin_id:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Not an impersonation token")
result = await db.execute(select(User).where(User.id == admin_id))
admin = result.scalar_one_or_none()
if not admin or not admin.is_admin:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Admin not found")
return Token(access_token=create_token(admin.id))

View File

@@ -37,3 +37,25 @@ class Token(BaseModel):
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str password: str
class AdminUserCreate(BaseModel):
email: EmailStr
password: str
display_name: Optional[str] = None
class AdminPasswordReset(BaseModel):
new_password: str
class AdminUserResponse(BaseModel):
id: int
email: str
display_name: Optional[str]
timezone: str
is_admin: bool
is_disabled: bool
created_at: datetime
model_config = {"from_attributes": True}

View File

@@ -17,9 +17,11 @@ def verify_password(plain: str, hashed: str) -> bool:
return pwd_context.verify(plain, hashed) return pwd_context.verify(plain, hashed)
def create_token(user_id: int) -> str: def create_token(user_id: int, admin_id: Optional[int] = None) -> str:
expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes) expire = datetime.now(timezone.utc) + timedelta(minutes=settings.access_token_expire_minutes)
payload = {"sub": str(user_id), "exp": expire} payload: dict = {"sub": str(user_id), "exp": expire}
if admin_id is not None:
payload["admin_id"] = admin_id
return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm) return jwt.encode(payload, settings.secret_key, algorithm=settings.algorithm)
@@ -32,3 +34,10 @@ def decode_token(token: str) -> Optional[int]:
return int(user_id) return int(user_id)
except JWTError: except JWTError:
return None return None
def decode_token_full(token: str) -> Optional[dict]:
try:
return jwt.decode(token, settings.secret_key, algorithms=[settings.algorithm])
except JWTError:
return None

View File

@@ -16,6 +16,8 @@ services:
- DATABASE_URL=${DATABASE_URL} - DATABASE_URL=${DATABASE_URL}
- SECRET_KEY=${SECRET_KEY} - SECRET_KEY=${SECRET_KEY}
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-480} - ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES:-480}
- ADMIN_USERNAME=${ADMIN_USERNAME}
- ADMIN_PASSWORD=${ADMIN_PASSWORD}
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy