diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..e5acf95 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.gitignore +.github +.venv +venv +__pycache__ +*.pyc +.pytest_cache +.mypy_cache +.ruff_cache +.coverage +htmlcov +*.md +docs +tests +.env +.env.* +.DS_Store +.vscode +.idea +pg_data diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..548621a --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# YPMS local development environment +# +# Copy this file to .env for local runs. Do NOT commit .env. +# Production values live in Azure Key Vault (Phase 1+). + +# ---- Database ---- +# Local (host) → Postgres exposed on 5433 to avoid clashing with OWC on 5432 +DATABASE_URL=postgresql+psycopg://ypms:ypms@localhost:5433/ypms + +# ---- JWT ---- +JWT_SECRET=dev-secret-change-me +JWT_ALG=HS256 +ACCESS_TOKEN_TTL_MINUTES=60 +REFRESH_TOKEN_TTL_DAYS=14 + +# ---- App-layer column encryption ---- +# Phase 0: local key material only. Phase 1 swaps to Azure Key Vault. +# Any 32-byte value in dev. Never use this string in staging/prod. +COLUMN_ENCRYPTION_KEY=dev-col-enc-key-change-me-32bytes-______ + +# ---- Logging + environment ---- +LOG_LEVEL=INFO +ENV=dev + +# ---- CORS (dev only) ---- +# Comma-separated origins allowed to hit the API from a browser. +CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..5cf1c90 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,47 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + + - name: Install + run: | + python -m pip install --upgrade pip + pip install -e '.[dev]' + + - name: Lint (ruff) + run: ruff check src tests + + - name: Type check (mypy) + run: mypy src + + - name: Tests + coverage + run: pytest --cov=src/ypms --cov-report=term-missing --cov-fail-under=80 + + - name: SAST (bandit) + run: bandit -r src -c pyproject.toml + + - name: SCA (pip-audit) + run: pip-audit --strict --disable-pip + + - name: Gitleaks + uses: gitleaks/gitleaks-action@v2 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..6a71d6c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,52 @@ +# Contributing to YPMS + +Hard rules — mirrored from OWC's `CONTRIBUTING.md` because the two systems share a stack, an ownership model, and an ISSO. If a rule feels wrong for YPMS, propose an amendment in a PR to this file, don't quietly break it. + +## Repository conventions + +1. **Migrations are immutable once merged.** Add a new Alembic revision, never edit `0001_baseline.py` or its successors. If a schema change requires undoing an older migration, the fix is a new revision, not a rewrite. Every migration is a paper trail an auditor can walk. + +2. **Don't add domain logic to `src/ypms/framework/` or `src/ypms/security/`.** Those are reused by every entity; entity-specific code belongs in `src/ypms//`. If a framework enhancement is genuinely needed, propose it in its own PR with a written reason. + +3. **Every state-mutating route emits an audit row.** Enforced by `CRUDService` and the router factory once the framework lands. Don't write handlers that bypass either. + +4. **501 stubs still fire RBAC + audit.** Deferred actions must route through `require_permission` and emit an audit event. Permission gaps surface at Phase 0 review, not at feature-completion. + +5. **Tests required.** `src/ypms/framework/` and `src/ypms/security/` need ≥ 80% coverage. Everything else: at least one happy-path test. + +6. **No `--no-verify`, `# noqa`, or `# type: ignore` without an inline comment naming the specific reason.** If you need to silence a check, say why in the code. + +7. **Sensitive fields never in logs.** SSN, banking, tokens, passwords, encryption keys — route through `sanitize_for_log` before emitting or don't emit. See `src/ypms/logging.py`. + +8. **No commits attributed to a person who didn't write them.** Set `user.name` and `user.email` in `git config` to yourself before committing. Co-author trailers are welcome and encouraged for pairing. + +## Change control + +- Every substantive change ties to a POA&M ID, a phase-plan milestone, or an issue. Include the reference in the PR description. +- `PHASE{N}_PLAN.md` is authoritative for the current phase's scope. Older plans lose to the current one on conflict. +- Cross-phase work needs a written scope-conflict note before it lands. + +## Security review + +- Any change touching auth, RBAC, encryption, or audit gets a second reviewer. +- Changes to `src/ypms/security/` require the ISSO's approval (currently Chris Tonjes). +- Additions to the RBAC seed require the same review as code changes — permissions are policy in this codebase. + +## PR checklist + +- [ ] Tests pass locally (`pytest`). +- [ ] Coverage doesn't drop below 80% on `framework/` or `security/`. +- [ ] `ruff check`, `mypy` clean. +- [ ] SAST (`bandit`) and SCA (`pip-audit`) clean, or documented waivers. +- [ ] Migration added (if schema changed); no edits to older migrations. +- [ ] Any new endpoint has RBAC + audit + a test. +- [ ] Any new sensitive field routes through the app-layer encryption helpers. +- [ ] `docs/nist-mapping.md` updated if the change wires a new NIST control. +- [ ] POA&M item updated if the change closes or advances one. +- [ ] Commit message names the phase milestone or POA&M ID it advances. + +## Merging + +- Squash-merge is the default. Merge commit messages should read like release notes for that PR. +- Force-pushes to `main` are not allowed. Force-pushes to feature branches during review are fine. +- CI must be green. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..875caa6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim AS builder +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends build-essential && rm -rf /var/lib/apt/lists/* +COPY pyproject.toml ./ +COPY src ./src +RUN pip install --no-cache-dir --prefix=/install . + +FROM python:3.12-slim AS runtime +WORKDIR /app +RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/* +COPY --from=builder /install /usr/local +COPY src ./src +COPY migrations ./migrations +COPY alembic.ini ./ +ENV PYTHONPATH=/app/src +ENV PYTHONUNBUFFERED=1 +EXPOSE 8000 +CMD ["uvicorn", "ypms.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..272e471 --- /dev/null +++ b/README.md @@ -0,0 +1,83 @@ +# YPMS — Youth Program Management System + +DC Department of Employment Services / Office of Youth Programs — in-house build replacing seven legacy tools (MBSYEP intake, TMS timekeeping, PF3 admin, manual payroll scripts, MicroStrategy, and two supporting apps) with a single Python / FastAPI / Postgres system on Azure Government. + +**Status:** Pre-development. Phase 0 (foundations) in flight. +**System owner + ISSO + AO:** Chris Tonjes, DOES CIO. +**Full context:** [`OVERVIEW.md`](OVERVIEW.md), [`ARCHITECTURE.md`](ARCHITECTURE.md), [`PHASE0_PLAN.md`](PHASE0_PLAN.md). + +## Quickstart (local) + +Requires Docker Desktop (or equivalent). Ports 8000 (app), 5433 (Postgres), 8081 (Adminer) must be free. + +```bash +cp .env.example .env +docker compose up --build +``` + +App at http://localhost:8000. OpenAPI docs at http://localhost:8000/docs. + +```bash +curl http://localhost:8000/healthz # → {"status":"ok"} +curl http://localhost:8000/readyz # → {"status":"ok"} once db is up +``` + +Adminer (DB explorer) at http://localhost:8081. Connect with: +- System: PostgreSQL +- Server: `db` +- Username / Password / Database: `ypms` / `ypms` / `ypms` + +Stop with `Ctrl+C`; wipe local DB with `docker compose down -v`. + +## Tests + +Local (no Docker required for the tests): + +```bash +python -m venv .venv && source .venv/bin/activate +pip install -e '.[dev]' +pytest +``` + +Coverage: + +```bash +pytest --cov=src/ypms --cov-report=term-missing +``` + +Tests use SQLite in-memory for speed. Postgres-specific behaviour gets its own Postgres-only fixture when a migration or query needs it (see [`ARCHITECTURE.md`](ARCHITECTURE.md) §4.8). + +## Repo shape + +Full tree in [`ARCHITECTURE.md`](ARCHITECTURE.md) §5.1. High points: + +- `src/ypms/main.py` — FastAPI factory +- `src/ypms/settings.py` — pydantic-settings config +- `src/ypms/db.py` — SQLAlchemy engine, session, `Base` +- `src/ypms/logging.py` — structlog + sensitive-field sanitizer +- `src/ypms/middleware.py` — request id +- `src/ypms/framework/` — vendored from OWC, milestone 0.2 +- `src/ypms/security/` — auth + RBAC + column encryption, milestones 0.3–0.4 +- `src/ypms/youth/` — Registrant entity (vertical slice), milestone 0.5 +- Other entity packages — 501 stubs, milestone 0.6 +- `migrations/` — Alembic, immutable-once-merged +- `tests/` — pytest, SQLite by default + +## Adding an entity + +Once the framework lands in milestone 0.2, adding a new entity follows the OWC add-entity pattern (model → schemas → router → main.py registration → migration → permissions seed entry → test → coverage doc). Full instructions land here at milestone 0.2 completion. + +## Contributing + +See [`CONTRIBUTING.md`](CONTRIBUTING.md) for the hard rules. Highlights: migrations are immutable once merged, every state-mutating route emits an audit row, framework/security modules must not carry domain logic, no `--no-verify` or `# type: ignore` without a written reason. + +## Compliance + +- [`SYSTEM_SECURITY_PLAN.md`](SYSTEM_SECURITY_PLAN.md) v0.3 — NIST 800-53 Moderate SSP +- [`NIST_800_53_CONTROL_MAPPING.md`](NIST_800_53_CONTROL_MAPPING.md) v0.2 — full control-family map +- [`PRIVACY_IMPACT_ASSESSMENT.md`](PRIVACY_IMPACT_ASSESSMENT.md) v0.1 — PIA covering minors, SSN, banking, FERPA, CFSA +- [`POAM.md`](POAM.md) v0.1 — POA&M with 62 items and 2 closures as of 2026-09-30 + +## License / usage + +Internal DOES system. Not for external distribution. diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..4aee1b1 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = migrations +prepend_sys_path = src +version_path_separator = os +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d56da71 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,50 @@ +services: + db: + image: postgres:16 + environment: + POSTGRES_USER: ypms + POSTGRES_PASSWORD: ypms + POSTGRES_DB: ypms + TZ: UTC + PGTZ: UTC + volumes: + - pg_data:/var/lib/postgresql/data + ports: + - "5433:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ypms"] + interval: 3s + timeout: 3s + retries: 20 + + app: + build: . + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql+psycopg://ypms:ypms@db:5432/ypms + JWT_SECRET: dev-secret-change-me + JWT_ALG: HS256 + ACCESS_TOKEN_TTL_MINUTES: 60 + COLUMN_ENCRYPTION_KEY: dev-col-enc-key-change-me-32bytes-______ + LOG_LEVEL: INFO + ENV: dev + ports: + - "8000:8000" + command: > + sh -c "alembic upgrade head && + uvicorn ypms.main:app --host 0.0.0.0 --port 8000 --reload" + volumes: + - ./src:/app/src + - ./migrations:/app/migrations + + adminer: + image: adminer:4 + depends_on: + - db + ports: + - "8081:8080" + +volumes: + pg_data: diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..9285c08 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,55 @@ +"""Alembic environment. + +At milestone 0.1 the metadata is empty (no models yet). Milestones 0.5 and 0.6 +register real and stub entity models, at which point `target_metadata` will +carry their tables. `alembic upgrade head` currently applies a no-op baseline. +""" + +from __future__ import annotations + +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from ypms.db import Base +from ypms.settings import get_settings + +# ---- Import model modules here so Base.metadata is populated ---- +# (empty at milestone 0.1; entities register themselves via later milestones) + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +config.set_main_option("sqlalchemy.url", get_settings().database_url) +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=config.get_main_option("sqlalchemy.url"), + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + connectable = engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..891119b --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,27 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision: str = ${repr(up_revision)} +down_revision: str | Sequence[str] | None = ${repr(down_revision)} +branch_labels: str | Sequence[str] | None = ${repr(branch_labels)} +depends_on: str | Sequence[str] | None = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/0001_baseline.py b/migrations/versions/0001_baseline.py new file mode 100644 index 0000000..2bd5367 --- /dev/null +++ b/migrations/versions/0001_baseline.py @@ -0,0 +1,32 @@ +"""Baseline — empty schema. + +Revision ID: 0001_baseline +Revises: +Create Date: 2026-09-30 + +Milestone 0.1 baseline. Deliberately empty — proves that `alembic upgrade head` +runs against a fresh database. Real tables land as additive migrations in +milestones 0.5 (Registrant) and 0.6 (stub-entity metadata). + +Per repo rule (CONTRIBUTING.md): this file is immutable once merged. Every +subsequent change is a new revision, never an edit here. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +revision: str = "0001_baseline" +down_revision: str | Sequence[str] | None = None +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Intentionally empty. See module docstring. + pass + + +def downgrade() -> None: + # Nothing to undo. + pass diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..cdf3355 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,69 @@ +[project] +name = "ypms" +version = "0.1.0" +description = "DC Youth Program Management System" +requires-python = ">=3.12" +dependencies = [ + "fastapi>=0.115", + "uvicorn[standard]>=0.30", + "sqlalchemy>=2.0", + "alembic>=1.13", + "psycopg[binary]>=3.2", + "pydantic[email]>=2.8", + "pydantic-settings>=2.4", + "passlib>=1.7", + "python-jose[cryptography]>=3.3", + "python-multipart>=0.0.9", + "structlog>=24.4", + "cryptography>=43", + "jinja2>=3.1", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.3", + "pytest-asyncio>=0.24", + "pytest-cov>=5.0", + "httpx>=0.27", + "ruff>=0.6", + "mypy>=1.11", + "bandit>=1.7", + "pip-audit>=2.7", +] + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +testpaths = ["tests"] +asyncio_mode = "auto" +addopts = "-ra --strict-markers" + +[tool.coverage.run] +source = ["src/ypms"] +omit = ["tests/*"] + +[tool.coverage.report] +show_missing = true +skip_covered = false + +[tool.ruff] +line-length = 100 +target-version = "py312" + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM"] +# E501: handled by formatter; B008: FastAPI's Depends() in defaults is canonical +ignore = ["E501", "B008"] + +[tool.mypy] +python_version = "3.12" +strict = true +ignore_missing_imports = true + +[tool.bandit] +exclude_dirs = ["tests", "migrations"] diff --git a/src/ypms/__init__.py b/src/ypms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ypms/db.py b/src/ypms/db.py new file mode 100644 index 0000000..efa63ea --- /dev/null +++ b/src/ypms/db.py @@ -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() diff --git a/src/ypms/framework/__init__.py b/src/ypms/framework/__init__.py new file mode 100644 index 0000000..f50c03b --- /dev/null +++ b/src/ypms/framework/__init__.py @@ -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. +""" diff --git a/src/ypms/logging.py b/src/ypms/logging.py new file mode 100644 index 0000000..38e6c14 --- /dev/null +++ b/src/ypms/logging.py @@ -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 diff --git a/src/ypms/main.py b/src/ypms/main.py new file mode 100644 index 0000000..7cc560f --- /dev/null +++ b/src/ypms/main.py @@ -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() diff --git a/src/ypms/middleware.py b/src/ypms/middleware.py new file mode 100644 index 0000000..db1d01d --- /dev/null +++ b/src/ypms/middleware.py @@ -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 diff --git a/src/ypms/security/__init__.py b/src/ypms/security/__init__.py new file mode 100644 index 0000000..cf4e8ff --- /dev/null +++ b/src/ypms/security/__init__.py @@ -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. +""" diff --git a/src/ypms/settings.py b/src/ypms/settings.py new file mode 100644 index 0000000..f6bfef5 --- /dev/null +++ b/src/ypms/settings.py @@ -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] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..a54bc23 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,35 @@ +"""Shared pytest fixtures. + +Uses SQLite for speed (see ARCHITECTURE.md §4.8). Any test that needs +Postgres-specific behaviour lives in a Postgres-only fixture path (added when a +migration or query needs it). +""" + +from __future__ import annotations + +import os +from collections.abc import Iterator + +import pytest +from fastapi.testclient import TestClient + + +@pytest.fixture(scope="session", autouse=True) +def _test_env() -> Iterator[None]: + """Set minimum env vars required for `Settings()` to construct in tests.""" + os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:") + os.environ.setdefault("JWT_SECRET", "test-secret") + os.environ.setdefault( + "COLUMN_ENCRYPTION_KEY", "test-col-enc-key-32bytes-______________" + ) + os.environ.setdefault("ENV", "test") + yield + + +@pytest.fixture +def client() -> Iterator[TestClient]: + from ypms.main import create_app + + app = create_app() + with TestClient(app) as c: + yield c diff --git a/tests/test_health.py b/tests/test_health.py new file mode 100644 index 0000000..ff6208a --- /dev/null +++ b/tests/test_health.py @@ -0,0 +1,30 @@ +"""Milestone 0.1 smoke tests. + +Proves: +- `/healthz` returns 200 without any dependency access. +- Every response carries an `X-Request-ID` header (RequestIdMiddleware wired). +- The header is echoed back when the caller supplies one. +""" + +from __future__ import annotations + +from fastapi.testclient import TestClient + + +def test_healthz_returns_ok(client: TestClient) -> None: + r = client.get("/healthz") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} + + +def test_response_carries_request_id(client: TestClient) -> None: + r = client.get("/healthz") + assert "x-request-id" in {k.lower() for k in r.headers} + req_id = r.headers["X-Request-ID"] + assert req_id + assert len(req_id) >= 8 + + +def test_incoming_request_id_is_honoured(client: TestClient) -> None: + r = client.get("/healthz", headers={"X-Request-ID": "test-req-abcd"}) + assert r.headers["X-Request-ID"] == "test-req-abcd"