# v0.27.0 — Cutover guide

> **Audience:** consumer agents (and humans relaying for them)
> upgrading to `mgf-common` 0.27.0.
> **What this is:** the canonical migration document for this
> release. Per [`RELEASING.md`](../standards/RELEASING.md) **REL-09**
> + the cutover-guide pattern, individual per-entry notification
> paragraphs are pointers into this guide.

This release closes the rest of PlasmaMapper's Phase-3 paper batch:
**PAPER-27** (`IpAllowlist`) and **PAPER-28** (`run_test_app`). Both
🟢 nice-to-haves filed in the same commit as PAPER-26 (already
shipped in v0.26.0 same day). Both additive — no behaviour change
for existing consumers.

---

## At-a-glance

| Entry | Severity | What landed | Code or docs? |
|---|---|---|---|
| [PAPER-27](#paper-27--ipallowlist) | 🟢 | `mgf.common.fastapi.security.IpAllowlist` | code |
| [PAPER-28](#paper-28--run_test_app) | 🟢 | `mgf.common.fastapi.testing.run_test_app` | code |

**Pin bump:** `mgf-common = ">=0.27,<0.28"` per the 0.x window
discipline (one minor at a time; AP-03).

**Public-Python-surface change:** two new public names. `IpAllowlist`
under `mgf.common.fastapi.security`; `run_test_app` under
`mgf.common.fastapi.testing`. Both subpackage imports — not
re-exported at the top of `mgf.common.fastapi`.

**Behaviour change:** none. Strictly additive.

**Optional-extra change:** `uvicorn>=0.30` added to `[fastapi-test]`
(needed by `run_test_app`). Existing consumers of `[fastapi-test]`
auto-install on the next `uv sync`.

**No deprecation cycles introduced.**

---

## PAPER-27 — `IpAllowlist`

A FastAPI dependency that 403s requests whose client IP isn't in a
configured CIDR allowlist. The pattern is well-trodden — every web
app eventually grows an "ops-only" or "staging-only" endpoint that
needs a network gate before SaaS auth is wired (or alongside it as
defense-in-depth).

### What changed

```python
from fastapi import Depends, FastAPI
from mgf.common.fastapi.security import IpAllowlist

admin_only = IpAllowlist(
    cidrs=["10.0.0.0/8", "192.168.1.0/24"],
    trust_proxy=False,    # explicit; default
)

app = FastAPI()

@app.post("/admin/trackers", dependencies=[Depends(admin_only)])
async def create_tracker(): ...
```

The dependency raises `HttpForbidden` (HTTP-01 leaf, 403) on miss.
With `ExceptionTranslationMiddleware` installed the response shape
is the standard typed-error JSON; without it the framework's
default 403 page applies.

### The proxy-trust knob

This is the load-bearing piece. By default the dependency reads
`request.client.host` directly. **Behind a reverse proxy
(nginx, traefik, ALB, Cloudflare), every request appears to
originate from the proxy.** To honour the originating client's IP,
you need TWO things:

1. **A forwarded-headers middleware** in front of the dependency
   so `X-Forwarded-For` (and friends) propagate to
   `request.client.host`. Common choices:
   - Starlette's `ProxyHeadersMiddleware` (when running behind
     uvicorn with `--proxy-headers`).
   - A custom middleware that sets `request.scope["client"]` from
     the X-Forwarded-For header.

2. **OR** opt in to `IpAllowlist`'s built-in XFF reading:
   `IpAllowlist(..., trust_proxy=True)`. This honours the FIRST
   entry in `X-Forwarded-For` — the originating client per the
   convention.

**The default is `trust_proxy=False`** because honouring forwarded
headers without proxy trust is the canonical IP-spoofing footgun:
any client can set `X-Forwarded-For` and bypass the gate.

### IPv4 + IPv6 dual-stack

CIDRs in either family are accepted; matching is per-version. An
IPv4 client against an IPv6 allowlist deliberately misses (no
IPv4-mapped-IPv6 cross-match) — explicit allowlist for the version
you care about.

### Default — loopback

`IpAllowlist()` (no args) defaults to loopback-only
(`127.0.0.0/8`, `::1/128`). The right default for tests; consumers
MUST override for production.

### Migration — replacing a per-consumer admin-IP gate

For PlasmaMapper specifically (filing consumer):

**Before** — `src/plasmamapper/api/security/admin_ip.py`:

```python
from functools import lru_cache
import ipaddress
from fastapi import Request
from mgf.common.exceptions import HttpForbidden

@lru_cache(maxsize=64)
def _parse_cidr(cidr): ...

def require_admin_ip(request: Request) -> None:
    settings = request.app.state.settings
    allowlist = settings.admin_ip_allowlist
    # ~70 lines of parse, version-match, error log, raise HttpForbidden
```

**After**:

```python
from mgf.common.fastapi.security import IpAllowlist

# Constructed at app-startup time (one allowlist per deployment):
admin_ip_gate = IpAllowlist(
    cidrs=settings.admin_ip_allowlist,
    trust_proxy=settings.behind_reverse_proxy,
)

@app.post("/admin/trackers", dependencies=[Depends(admin_ip_gate)])
async def create_tracker(): ...
```

The 70-line `require_admin_ip.py` deletes. The `lru_cache` parsing,
version-match logic, error-logging, and `HttpForbidden` raise all
move to the library.

### What's deliberately NOT in v0.27

Filed for follow-up papers when a consumer asks:

- **`Request`-stateful settings reading.** PlasmaMapper's version
  reads `request.app.state.settings.admin_ip_allowlist` per
  request — meaning the allowlist can change at runtime via
  settings reload. Our version takes the CIDR list at construction
  time. If runtime mutability matters, file a follow-up paper.
- **Multiple forwarded-header schemes.** Some proxies use
  `Forwarded:` (RFC 7239) instead of `X-Forwarded-For`. We honour
  XFF only at v0.27.
- **`X-Real-IP` support.** Same shape — different deployments use
  different headers. File when the second consumer asks.

---

## PAPER-28 — `run_test_app`

An async context manager that starts uvicorn for a `FastAPI` app on
a free port and yields the base URL. Replaces ~120 LOC per consumer
that ran into the "Playwright golden-paths against real uvicorn"
pattern (per ADR-013 in PlasmaMapper).

### What changed

```python
from mgf.common.fastapi.testing import run_test_app

async def test_my_endpoint():
    app = build_my_test_app()  # consumer-side fixture
    async with run_test_app(app) as base_url:
        async with httpx.AsyncClient(base_url=base_url) as client:
            response = await client.get("/health")
            assert response.status_code == 200
```

Defaults:

- **Port:** free ephemeral port (the right answer for parallel test runs).
- **Host:** `127.0.0.1` (never expose a test server externally).
- **Log level:** `warning` (uvicorn's `info` chatter crowds test output).
- **Startup timeout:** 5 seconds (raises `TimeoutError` if the
  lifespan blocks beyond that).

### Server-startup failures surface immediately

If uvicorn's serve task crashes during startup (port collision after
the free-port pick, lifespan handler raises, etc.), the helper
re-raises the underlying exception instead of timing out silently.
This was the load-bearing test-DX fix — silent timeouts on a CI
flake are nearly impossible to debug.

### Clean shutdown is guaranteed

The context manager's `finally` branch signals uvicorn to exit and
awaits the serve task; `CancelledError` from the awaited task is
suppressed (it's the expected outcome of a clean shutdown, not a
failure). Tests that raise INSIDE the context still tear down
correctly; the bound port is reusable immediately after exit.

### Migration — replacing a per-consumer test API server

For PlasmaMapper specifically (filing consumer):

**Before** — `tests/ux/fixtures/api_server.py`:

```python
class TestApiServer:
    def __init__(self, *, port=None, settings=None): ...
    @property
    def base_url(self) -> str: ...
    async def start(self) -> str:
        # ... build app, build uvicorn.Config, create_task,
        #     poll for server.started, ~50 lines ...
    async def stop(self) -> None:
        # ... should_exit, await task, suppress CancelledError ...

@asynccontextmanager
async def run_test_server(*, port=None, settings=None, seed=True):
    server = TestApiServer(port=port, settings=settings)
    if seed:
        await server.seed()
    await server.start()
    try:
        yield server
    finally:
        await server.stop()
```

**After** — `tests/ux/fixtures/api_server.py`:

```python
from contextlib import asynccontextmanager
from mgf.common.fastapi.testing import run_test_app

@asynccontextmanager
async def run_test_server(*, port=None, settings=None, seed=True):
    settings = settings or build_test_settings()
    if seed:
        engine = create_engine_from_settings(settings)
        try:
            world = await reset_and_seed(engine)
            await seed_auth_boundary_world(engine)
        finally:
            await engine.dispose()

    app = create_app(settings)
    async with run_test_app(app, port=port) as base_url:
        yield base_url, world  # or a richer object with .seeded etc.
```

The `TestApiServer` class deletes. The seed step stays
consumer-side (test-DB-specific). The lifecycle dance is gone.

### What's deliberately NOT in v0.27

- **Sync-context support.** `run_test_app` is async-only. A sync
  flavour (for non-asyncio test frameworks) is a follow-up paper.
- **Multi-app orchestration.** Want to run two FastAPI apps in
  the same test? Use two `async with run_test_app(...)` blocks.
  No batched primitive.
- **Live-reload / restart.** Out of scope — tests don't need it,
  and consumer-side `dev`-mode harnesses do this differently.

---

## Per-consumer migration notes

### vm-vmanager-app (Qt desktop)

**No changes required.** Web-domain release; doesn't touch your
project's surface. Bump pin to `mgf-common = ">=0.27,<0.28"`.

### inthewords (Django web)

**No changes required.** Both new helpers are FastAPI-coupled.
Bump pin to `mgf-common = ">=0.27,<0.28"`. If you ever add a
FastAPI sidecar (admin tool, webhook receiver), they're available.

### PlasmaMapper (FastAPI scientific platform — filing consumer)

**You are the migration consumer.** Two refactors land:

1. Replace `src/plasmamapper/api/security/admin_ip.py` with
   `IpAllowlist`. Delete the file; instantiate
   `IpAllowlist(cidrs=settings.admin_ip_allowlist,
   trust_proxy=settings.behind_reverse_proxy)` at app-startup
   time; wire via `Depends(admin_ip_gate)`.

2. Refactor `tests/ux/fixtures/api_server.py` around
   `run_test_app`. The `TestApiServer` class deletes; seeding
   stays in consumer code. Net: ~70 LOC removed.

If your migration surfaces a missing knob on either helper, file a
follow-up paper (PAPER-27 retrospective explicitly invites this).

---

## Engineering note

v0.27.0 is the **second same-week release in the v0.26 batch
sequence** (PAPER-26 → v0.26.0; PAPER-27 + 28 → v0.27.0). Same
submitter (PlasmaMapper), same paper batch, split across two
releases for review-friction reasons: PAPER-26 was security-shaped
and deserved its own focused PR; PAPER-27 + 28 are the smaller
follow-ups. Both add new public surface, so both warrant MINOR
bumps under AP-03's 0.x discipline.

The split + bundle pattern (one paper per release vs all-in-one)
is recorded as a release-engineering data point — both shapes work
for a paper batch from one consumer; the choice is reviewer-
friction-shaped, not policy-shaped.
