feat(phase-4a): backend skeleton, auth, health, tests

This commit is contained in:
CRM Bot
2026-06-03 20:52:01 +00:00
commit 955607f730
39 changed files with 2709 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Core module: configuration, database, security, dependencies."""
+86
View File
@@ -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]
+69
View File
@@ -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()
+68
View File
@@ -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
+101
View File
@@ -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