feat(phase-4a): backend skeleton, auth, health, tests
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Core module: configuration, database, security, dependencies."""
|
||||
@@ -0,0 +1,86 @@
|
||||
"""Application configuration via Pydantic-Settings v2.
|
||||
|
||||
Loads from .env file and environment variables.
|
||||
Hard-fails if AUTH_SECRET is missing or shorter than 32 characters
|
||||
(per 02-architecture.md Section 13.5 R-5: NO JWT secret fallback).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""Application settings loaded from environment."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# === Database ===
|
||||
DATABASE_URL: str = "sqlite+aiosqlite:///./dev.db"
|
||||
|
||||
# === JWT / Auth ===
|
||||
# NO DEFAULT — hard-fail if missing (R-5)
|
||||
AUTH_SECRET: str = Field(..., min_length=32)
|
||||
JWT_ALGORITHM: str = "HS256"
|
||||
JWT_EXPIRY_HOURS: int = 24
|
||||
|
||||
# === Password Hashing ===
|
||||
BCRYPT_ROUNDS: int = 12
|
||||
|
||||
# === CORS ===
|
||||
# Comma-separated list of allowed origins, NO wildcards
|
||||
CORS_ORIGINS: str = "http://localhost:5500,http://localhost:8000"
|
||||
|
||||
# === Runtime ===
|
||||
ENVIRONMENT: Literal["development", "production", "test"] = "development"
|
||||
LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "INFO"
|
||||
|
||||
@field_validator("AUTH_SECRET")
|
||||
@classmethod
|
||||
def validate_auth_secret(cls, v: str) -> str:
|
||||
"""Ensure AUTH_SECRET is at least 32 characters and not a known dev default."""
|
||||
if len(v) < 32:
|
||||
raise ValueError(
|
||||
f"AUTH_SECRET must be at least 32 characters (got {len(v)}). "
|
||||
"Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(48))'"
|
||||
)
|
||||
# Reject obvious placeholders
|
||||
lowered = v.lower()
|
||||
if "replace-me" in lowered or "changeme" in lowered or "secret" == lowered:
|
||||
raise ValueError("AUTH_SECRET appears to be a placeholder. Use a real random value.")
|
||||
return v
|
||||
|
||||
@property
|
||||
def cors_origins_list(self) -> list[str]:
|
||||
"""Parse CORS_ORIGINS into a list of trimmed, non-empty origins."""
|
||||
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
|
||||
|
||||
@property
|
||||
def is_production(self) -> bool:
|
||||
"""Check if running in production mode."""
|
||||
return self.ENVIRONMENT == "production"
|
||||
|
||||
@property
|
||||
def is_test(self) -> bool:
|
||||
"""Check if running in test mode."""
|
||||
return self.ENVIRONMENT == "test"
|
||||
|
||||
@property
|
||||
def jwt_expiry_seconds(self) -> int:
|
||||
"""JWT expiry in seconds (for response `expires_in` field)."""
|
||||
return self.JWT_EXPIRY_HOURS * 3600
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
"""Cached settings instance."""
|
||||
return Settings() # type: ignore[call-arg]
|
||||
@@ -0,0 +1,69 @@
|
||||
"""Async SQLAlchemy engine, session factory, and FastAPI dependency.
|
||||
|
||||
Driver-agnostic: works with both aiosqlite (dev) and asyncpg (prod).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import (
|
||||
AsyncEngine,
|
||||
AsyncSession,
|
||||
async_sessionmaker,
|
||||
create_async_engine,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
"""Declarative base for all ORM models.
|
||||
|
||||
Re-exported here so Alembic env.py can import it from a single location.
|
||||
"""
|
||||
|
||||
|
||||
# === Engine & Session Factory ===
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
# SQLite needs check_same_thread=False even for async — handled by aiosqlite.
|
||||
# echo=False in production; controlled by env if needed later.
|
||||
_engine_kwargs: dict[str, Any] = {"echo": False, "future": True}
|
||||
|
||||
# For PostgreSQL asyncpg, pool sizing matters; for SQLite it's irrelevant.
|
||||
if not _settings.DATABASE_URL.startswith("sqlite"):
|
||||
_engine_kwargs["pool_size"] = 5
|
||||
_engine_kwargs["max_overflow"] = 10
|
||||
_engine_kwargs["pool_pre_ping"] = True
|
||||
|
||||
engine: AsyncEngine = create_async_engine(_settings.DATABASE_URL, **_engine_kwargs)
|
||||
|
||||
AsyncSessionLocal: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
||||
bind=engine,
|
||||
expire_on_commit=False,
|
||||
class_=AsyncSession,
|
||||
autoflush=False,
|
||||
)
|
||||
|
||||
|
||||
# === Dependency ===
|
||||
|
||||
|
||||
async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields an AsyncSession and ensures cleanup."""
|
||||
async with AsyncSessionLocal() as session:
|
||||
try:
|
||||
yield session
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
# No explicit close needed — async context manager handles it.
|
||||
|
||||
|
||||
async def dispose_engine() -> None:
|
||||
"""Dispose of the engine on application shutdown."""
|
||||
await engine.dispose()
|
||||
@@ -0,0 +1,68 @@
|
||||
"""FastAPI dependency functions for auth and role checks."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.db import get_db
|
||||
from app.core.security import decode_access_token
|
||||
from app.models.user import User, UserRole
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
token: str = Depends(oauth2_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> User:
|
||||
"""Resolve the current authenticated user from the JWT in the Authorization header.
|
||||
|
||||
Raises 401 on invalid/expired token, missing user, or soft-deleted user.
|
||||
"""
|
||||
credentials_exc = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise credentials_exc
|
||||
|
||||
sub = payload.get("sub")
|
||||
if sub is None:
|
||||
raise credentials_exc
|
||||
|
||||
try:
|
||||
user_id = int(sub)
|
||||
except (ValueError, TypeError):
|
||||
raise credentials_exc from None
|
||||
|
||||
# Load user fresh from DB to honor soft-delete and role changes
|
||||
result = await db.execute(
|
||||
select(User).where(User.id == user_id, User.deleted_at.is_(None))
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
raise credentials_exc
|
||||
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_admin_user(
|
||||
user: User = Depends(get_current_user),
|
||||
) -> User:
|
||||
"""Require the current user to have the admin role."""
|
||||
user_role = (
|
||||
user.role.value if hasattr(user.role, "value") else str(user.role)
|
||||
)
|
||||
if user_role != UserRole.admin.value:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin role required",
|
||||
)
|
||||
return user
|
||||
@@ -0,0 +1,101 @@
|
||||
"""Password hashing and JWT encoding/decoding.
|
||||
|
||||
Uses python-jose[cryptography] (per 02-architecture.md Section 13.1) and
|
||||
passlib[bcrypt] with bcrypt 4.0.1 (per Section 13.6, 03a-patterns R-6).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from jose import JWTError, jwt
|
||||
from passlib.context import CryptContext
|
||||
|
||||
from app.core.config import get_settings
|
||||
|
||||
_settings = get_settings()
|
||||
|
||||
# === Password Hashing ===
|
||||
|
||||
# CryptContext auto-upgrades old hashes when verified. bcrypt 4.0.1 is pinned
|
||||
# because 4.1+ breaks passlib (per 03a-patterns-summary R-6).
|
||||
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
||||
|
||||
|
||||
def hash_password(plain: str) -> str:
|
||||
"""Hash a plaintext password using bcrypt with the configured rounds."""
|
||||
return pwd_context.hash(plain)
|
||||
|
||||
|
||||
def verify_password(plain: str, hashed: str) -> bool:
|
||||
"""Verify a plaintext password against a bcrypt hash."""
|
||||
try:
|
||||
return pwd_context.verify(plain, hashed)
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
|
||||
|
||||
# === JWT ===
|
||||
|
||||
|
||||
def create_access_token(
|
||||
user_id: int,
|
||||
org_id: int,
|
||||
role: str,
|
||||
expires_delta: timedelta | None = None,
|
||||
) -> str:
|
||||
"""Create a JWT access token with the given user, org, and role claims.
|
||||
|
||||
The token contains:
|
||||
- sub: user id (string)
|
||||
- org_id: organization id
|
||||
- role: user role
|
||||
- exp: expiry timestamp
|
||||
- iat: issued-at timestamp
|
||||
"""
|
||||
now = datetime.now(UTC)
|
||||
if expires_delta is None:
|
||||
expires_delta = timedelta(hours=_settings.JWT_EXPIRY_HOURS)
|
||||
expire = now + expires_delta
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"sub": str(user_id),
|
||||
"org_id": org_id,
|
||||
"role": role,
|
||||
"iat": int(now.timestamp()),
|
||||
"exp": int(expire.timestamp()),
|
||||
}
|
||||
return jwt.encode(payload, _settings.AUTH_SECRET, algorithm=_settings.JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> dict[str, Any] | None:
|
||||
"""Decode and validate a JWT access token.
|
||||
|
||||
Returns the payload dict on success, or None on any error
|
||||
(invalid signature, malformed token, expired).
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(
|
||||
token,
|
||||
_settings.AUTH_SECRET,
|
||||
algorithms=[_settings.JWT_ALGORITHM],
|
||||
)
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
|
||||
def is_token_expired(token: str) -> bool:
|
||||
"""Check whether a token is expired without raising on other errors."""
|
||||
try:
|
||||
jwt.get_unverified_claims(token)
|
||||
# If decode succeeds, it's not expired.
|
||||
jwt.decode(
|
||||
token, _settings.AUTH_SECRET, algorithms=[_settings.JWT_ALGORITHM]
|
||||
)
|
||||
return False
|
||||
except JWTError as e:
|
||||
return "expired" in str(e).lower() or "exp" in str(e).lower()
|
||||
except Exception:
|
||||
return True
|
||||
Reference in New Issue
Block a user