Phase 0 milestone 0.1: repo scaffold + Docker Compose
Scaffolding for the YPMS in-house build. Mirrors OWC's structure so future OWC framework code can be vendored in with minimal fiddling (milestone 0.2). Includes: - pyproject.toml — Python 3.12, FastAPI, SQLAlchemy 2.x, Alembic, Postgres, pydantic-settings, structlog, cryptography. Dev extras: pytest, ruff, mypy, bandit, pip-audit. - Dockerfile — multi-stage builder + slim runtime. - docker-compose.yml — app + Postgres (on 5433 to avoid clashing with OWC on 5432) + Adminer (on 8081). - alembic.ini + migrations/env.py + baseline 0001_baseline.py (empty by design; every future change is additive and immutable once merged). - src/ypms/main.py — FastAPI factory with /healthz and /readyz. - src/ypms/settings.py — pydantic-settings config (DB URL, JWT, column encryption key, log level, env, CORS). - src/ypms/logging.py — structlog + sanitize_for_log() for the sensitive-fields-never-in-logs rule from ARCHITECTURE.md §10.1. - src/ypms/db.py — SQLAlchemy Base, engine, session factory, get_db. - src/ypms/middleware.py — RequestIdMiddleware; X-Request-ID header echoed both ways. - src/ypms/framework/ and src/ypms/security/ — empty packages, to be populated in milestones 0.2 and 0.3-0.4. - tests/test_health.py — 3 smoke tests (healthz OK, header present, header honoured on incoming). - README.md — quickstart. - CONTRIBUTING.md — hard rules mirrored from OWC. - .github/workflows/ci.yml — lint (ruff), types (mypy), tests + 80% coverage floor, SAST (bandit), SCA (pip-audit), gitleaks. Green locally: ruff clean, mypy clean, pytest 3 passed. Advances YPMS-POAM-040 (architecture doc + Phase 0 kickoff). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
8712e93258
commit
393631ad69
23 files changed
+851
No files matched your search
Whitespace-only changes.
@@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
|
||||
from sqlalchemy import Engine, create_engine
|
||||
from sqlalchemy.orm import DeclarativeBase, Session, sessionmaker
|
||||
|
||||
from ypms.settings import get_settings
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
_engine: Engine | None = None
|
||||
_SessionLocal: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
def engine() -> Engine:
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(get_settings().database_url, pool_pre_ping=True, future=True)
|
||||
return _engine
|
||||
|
||||
|
||||
def session_factory() -> sessionmaker[Session]:
|
||||
global _SessionLocal
|
||||
if _SessionLocal is None:
|
||||
_SessionLocal = sessionmaker(bind=engine(), autoflush=False, autocommit=False, future=True)
|
||||
return _SessionLocal
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
db = session_factory()()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
@@ -0,0 +1,6 @@
|
||||
"""YPMS framework package.
|
||||
|
||||
Phase 0 milestone 0.2 will vendor in OWC's `framework/` modules (base entity,
|
||||
mixins, CRUDService, router factory, audit emitter, pagination) and add YPMS's
|
||||
`pii_audit.py` decorator on top. This package is empty at milestone 0.1.
|
||||
"""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Structured logging setup.
|
||||
|
||||
JSON logs in non-dev environments so Sentinel can ingest them cleanly. Console
|
||||
renderer in dev for readability.
|
||||
|
||||
Sensitive fields (SSN, banking account numbers, tokens, passwords) MUST NEVER
|
||||
be logged. The `sanitize_for_log` helper strips them before emit; use it whenever
|
||||
you log a dict that could contain PII. Callers that pass raw request bodies are
|
||||
a Phase 0 bug — fix them, don't paper over with a broader sanitizer here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import structlog
|
||||
|
||||
from ypms.settings import get_settings
|
||||
|
||||
_SENSITIVE_KEYS = {
|
||||
"ssn",
|
||||
"ssn_encrypted",
|
||||
"password",
|
||||
"password_hash",
|
||||
"secret",
|
||||
"token",
|
||||
"access_token",
|
||||
"refresh_token",
|
||||
"authorization",
|
||||
"banking_account_number",
|
||||
"routing_number",
|
||||
"column_encryption_key",
|
||||
"jwt_secret",
|
||||
}
|
||||
|
||||
|
||||
def configure_logging() -> None:
|
||||
settings = get_settings()
|
||||
level = getattr(logging, settings.log_level.upper(), logging.INFO)
|
||||
logging.basicConfig(format="%(message)s", stream=sys.stdout, level=level)
|
||||
|
||||
processors: list[Any] = [
|
||||
structlog.contextvars.merge_contextvars,
|
||||
structlog.processors.add_log_level,
|
||||
structlog.processors.TimeStamper(fmt="iso"),
|
||||
]
|
||||
if settings.env == "dev":
|
||||
processors.append(structlog.dev.ConsoleRenderer())
|
||||
else:
|
||||
processors.append(structlog.processors.JSONRenderer())
|
||||
|
||||
structlog.configure(
|
||||
processors=processors,
|
||||
wrapper_class=structlog.make_filtering_bound_logger(level),
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
cache_logger_on_first_use=True,
|
||||
)
|
||||
|
||||
|
||||
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
|
||||
return structlog.get_logger(name) # type: ignore[no-any-return] # structlog stubs return Any
|
||||
|
||||
|
||||
def sanitize_for_log(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Return a shallow copy of `data` with sensitive keys redacted.
|
||||
|
||||
Callers that log request bodies, DB row dumps, or JWT contents should route
|
||||
through this first. Recursive on dicts; leaves lists alone (add case-by-case
|
||||
as needed).
|
||||
"""
|
||||
out: dict[str, Any] = {}
|
||||
for k, v in data.items():
|
||||
if k.lower() in _SENSITIVE_KEYS:
|
||||
out[k] = "REDACTED"
|
||||
elif isinstance(v, dict):
|
||||
out[k] = sanitize_for_log(v)
|
||||
else:
|
||||
out[k] = v
|
||||
return out
|
||||
@@ -0,0 +1,68 @@
|
||||
"""FastAPI application factory.
|
||||
|
||||
Phase 0 milestone 0.1: minimal shell that boots against Docker Compose, serves
|
||||
`/healthz` (liveness) and `/readyz` (dependency-aware readiness), and emits
|
||||
structured logs with a request id per request. Domain routers get added in
|
||||
subsequent milestones.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import FastAPI, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy import text
|
||||
|
||||
from ypms.db import engine
|
||||
from ypms.logging import configure_logging, get_logger
|
||||
from ypms.middleware import RequestIdMiddleware
|
||||
from ypms.settings import get_settings
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
configure_logging()
|
||||
log = get_logger(__name__)
|
||||
settings = get_settings()
|
||||
|
||||
app = FastAPI(
|
||||
title="YPMS — Youth Program Management System",
|
||||
version="0.1.0",
|
||||
description="DC DOES Office of Youth Programs — in-house build (Phase 0).",
|
||||
)
|
||||
|
||||
# Middleware order: RequestId first so CORS responses carry the id too.
|
||||
app.add_middleware(RequestIdMiddleware)
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=list(settings.cors_origins),
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
expose_headers=["X-Request-ID"],
|
||||
)
|
||||
|
||||
@app.get("/healthz", tags=["system"])
|
||||
def healthz() -> dict[str, str]:
|
||||
"""Liveness — process is up. No dependency checks."""
|
||||
return {"status": "ok"}
|
||||
|
||||
@app.get("/readyz", tags=["system"])
|
||||
def readyz() -> dict[str, str]:
|
||||
"""Readiness — dependencies reachable. Fails 503 if the DB is unreachable."""
|
||||
try:
|
||||
with engine().connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
except Exception as e:
|
||||
log.warning("readyz.db_unreachable", error=str(e))
|
||||
from fastapi import HTTPException
|
||||
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={"status": "db_unreachable"},
|
||||
) from e
|
||||
return {"status": "ok"}
|
||||
|
||||
log.info("app.started", routes=len(app.routes), env=settings.env)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Cross-cutting middleware.
|
||||
|
||||
- RequestIdMiddleware: attach a short request id to every request, emit it as
|
||||
`X-Request-ID` in responses, and bind it into the structlog contextvars so
|
||||
JSON logs get a `req_id` field automatically for the duration of the request.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import structlog
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
|
||||
class RequestIdMiddleware(BaseHTTPMiddleware):
|
||||
HEADER = "X-Request-ID"
|
||||
|
||||
async def dispatch(self, request: Request, call_next) -> Response: # type: ignore[no-untyped-def]
|
||||
incoming = request.headers.get(self.HEADER)
|
||||
req_id = incoming or uuid.uuid4().hex[:12]
|
||||
request.state.req_id = req_id
|
||||
structlog.contextvars.clear_contextvars()
|
||||
structlog.contextvars.bind_contextvars(req_id=req_id)
|
||||
try:
|
||||
response: Response = await call_next(request)
|
||||
finally:
|
||||
structlog.contextvars.clear_contextvars()
|
||||
response.headers[self.HEADER] = req_id
|
||||
return response
|
||||
@@ -0,0 +1,6 @@
|
||||
"""YPMS security package.
|
||||
|
||||
Phase 0 milestone 0.3 lands JWT + local password auth + the `iam.assurance_level`
|
||||
claim, and milestone 0.4 lands the RBAC engine. This package is empty at
|
||||
milestone 0.1.
|
||||
"""
|
||||
@@ -0,0 +1,35 @@
|
||||
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")
|
||||
|
||||
# Database
|
||||
database_url: str
|
||||
|
||||
# JWT + auth
|
||||
jwt_secret: str
|
||||
jwt_alg: str = "HS256"
|
||||
access_token_ttl_minutes: int = 60
|
||||
refresh_token_ttl_days: int = 14
|
||||
|
||||
# App-layer column encryption
|
||||
# Phase 0: env var. Phase 1: Azure Key Vault.
|
||||
column_encryption_key: str
|
||||
|
||||
# Logging + environment
|
||||
log_level: str = "INFO"
|
||||
env: str = "dev"
|
||||
|
||||
# CORS (dev only — prod overrides)
|
||||
cors_origins: tuple[str, ...] = (
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
)
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings() # type: ignore[call-arg]
|
||||
Reference in new issue
Block a user