diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 6bb0899..629c183 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -48,6 +48,7 @@ /packages/auth/ @qiuethan /services/connectors/ @qiuethan /services/documentation-system/ @qiuethan +/services/gateway/ @qiuethan /services/llm/ @qiuethan /services/meeting/ @qiuethan /services/team-tracking/ @qiuethan diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3a5532a..d74b0d2 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -30,7 +30,7 @@ label-consistency.yml fails the build if you miss one — docs/CODE-OWNERSHIP.md Full list, and what each zone covers: docs/CODE-OWNERSHIP.md --> -`discord-bot` · `packages/auth` · `services/connectors` · `services/documentation-system` · `services/llm` · `services/meeting` · `services/team-tracking` · `services/verification` · `docs` · `scripts` · `.github` · `root` +`discord-bot` · `packages/auth` · `services/connectors` · `services/documentation-system` · `services/gateway` · `services/llm` · `services/meeting` · `services/team-tracking` · `services/verification` · `docs` · `scripts` · `.github` · `root` diff --git a/.github/labeler.yml b/.github/labeler.yml index a7573ac..20be217 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -44,6 +44,11 @@ - any-glob-to-any-file: - services/documentation-system/** +"zone: services/gateway": + - changed-files: + - any-glob-to-any-file: + - services/gateway/** + "zone: services/llm": - changed-files: - any-glob-to-any-file: @@ -74,6 +79,7 @@ - services/** - "!services/connectors/**" - "!services/documentation-system/**" + - "!services/gateway/**" - "!services/llm/**" - "!services/meeting/**" - "!services/team-tracking/**" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 67af12c..1a3a8d0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -213,6 +213,24 @@ jobs: - name: Ruff format check run: uv run ruff format --check . + gateway-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: services/gateway + steps: + - uses: actions/checkout@v4 + - name: Install uv + uses: astral-sh/setup-uv@v5 + - name: Install dependencies + run: uv sync --extra dev + - name: Run tests + run: uv run pytest + - name: Ruff check + run: uv run ruff check . + - name: Ruff format check + run: uv run ruff format --check . + node-test: runs-on: ubuntu-latest defaults: @@ -253,6 +271,10 @@ jobs: run: docker build -f services/llm/Dockerfile -t llm:ci . - name: Smoke-test llm image (imports resolve at boot) run: docker run --rm llm:ci python -c "import src.api.app" + - name: Build gateway image + run: docker build -f services/gateway/Dockerfile -t gateway:ci . + - name: Smoke-test gateway image (imports incl. contracts/ resolve at boot) + run: docker run --rm gateway:ci python -c "import src.api.app" - name: Build discord-bot image run: docker build -t discord-bot:ci ./discord-bot - name: Smoke-test discord-bot image (source parses) diff --git a/.github/workflows/pr-zone-check.yml b/.github/workflows/pr-zone-check.yml index 97a335f..5450c17 100644 --- a/.github/workflows/pr-zone-check.yml +++ b/.github/workflows/pr-zone-check.yml @@ -41,6 +41,7 @@ jobs: packages/*) echo packages/other ;; services/connectors/*) echo services/connectors ;; services/documentation-system/*) echo services/documentation-system ;; + services/gateway/*) echo services/gateway ;; services/llm/*) echo services/llm ;; services/meeting/*) echo services/meeting ;; services/team-tracking/*) echo services/team-tracking ;; diff --git a/README.md b/README.md index 4784a96..897f630 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,7 @@ Every domain has a first-class HTTP API — build your own dashboard, sync job, - **[team-tracking](services/team-tracking/README.md)** — 26 endpoints across `people`, `teams`, `role_kinds`, `team_memberships`, `providers`, `person_identifiers`, `api_keys`. Full point-in-time roster queries. Scoped API keys, per-request audit log. **Actively consumed** by the Discord bot in production. - **[documentation-system](services/documentation-system/README.md)** — endpoints over `docs` and `sources`; ingest a URL and it's normalized, dedup'd, fetched (title + snapshot for supported sources), and owner-validated against team-tracking. Ownership degrades gracefully if the directory is unreachable. **Consumed** by the Discord bot's `/doc` command group (`add`, `list`, `show`, `remove`). +- **[gateway](services/gateway/README.md)** — the one **public** service. A narrow, scoped, rate-limited door onto the directory for external consumers (e.g. a GitHub Action) that shouldn't hold an internal team-tracking key. First endpoint: `GET /v1/resolve/discord/{github_login}`, returning only a Discord id. Every service speaks OpenAPI. Point Swagger UI or codegen at them. (`meeting`'s WebSocket route isn't representable in OpenAPI — its wire format is documented in [`services/meeting/README.md`](services/meeting/README.md).) @@ -64,6 +65,7 @@ The other four are internal-facing: **[llm](services/llm/README.md)** (`POST /ch | [`services/verification/`](services/verification/README.md) | Email verification: request a one-time code and confirm it, linking a subject (e.g. `discord:`) to a verified email; requires the `verification:write` scope | **Deployed** (staging + prod). | | [`services/meeting/`](services/meeting/README.md) | Meeting recording: transcribes a Discord voice session (Amazon Transcribe) and returns LLM-generated minutes as a branded PDF; no DB, nothing persisted | **Deployed** (staging). Consumed by the bot's `/record` command group; requires the `meetings` scope. | | [`services/connectors/`](services/connectors/README.md) | Stateless outbound adapter: fetches document content (Google Docs/Sheets/Slides/Drive) on behalf of internal consumers via a service account; no DB | **Deployed** (staging). Consumed by documentation-system's Google source fetches; requires the `fetch` scope. | +| [`services/gateway/`](services/gateway/README.md) | The one **public** service — a scoped, rate-limited external gateway onto the directory, with its own external key registry and one internal team-tracking key | Built. First endpoint: `GET /v1/resolve/discord/{github_login}` (used by #34's reviewer-ping GitHub Action). | | [`discord-bot/`](discord-bot/README.md) | Discord slash-command frontend + a browser-based "web playground" for iterating on commands without a Discord token | **Deployed** (staging + prod). All slash commands are stable and registered globally; 0 beta. | | Search / retrieval | Full-text + semantic search over the catalog's snapshots | Deferred (not built) | @@ -123,14 +125,17 @@ Misty/ │ ├── llm/ Bedrock /chat proxy — 8002, NO database │ ├── meeting/ Live meeting transcription — 8004, NO database, │ │ stateful (in-memory sessions) -│ └── connectors/ Google source fetch adapter — 8005, NO database +│ ├── connectors/ Google source fetch adapter — 8005, NO database +│ └── gateway/ External API gateway — 8006, own Postgres (external +│ key registry). The one PUBLIC service: scoped, +│ rate-limited, curated read surface │ (every service above has the same docs/ set: │ API.md, ARCHITECTURE.md, CONTRIBUTING.md, DEPLOYMENT.md) │ ├── packages/ │ └── auth/ platform_auth — shared API-key auth lib (argon2 hashing, │ scopes, FastAPI deps, audit middleware); a pure leaf -│ consumed by all six services via thin shims +│ consumed by all seven services via thin shims │ ├── discord-bot/ Discord frontend + web playground │ ├── src/ Node.js + discord.js @@ -149,7 +154,7 @@ Misty/ ├── PULL_REQUEST_TEMPLATE.md Zone, verification steps, deployment notes ├── ISSUE_TEMPLATE/ Bug / feature / epic issue forms (Blocked by + Zone fields) └── workflows/ - ├── ci.yml Tests + lint + Docker builds on every PR (10 jobs) + ├── ci.yml Tests + lint + Docker builds on every PR (11 jobs) ├── main-source-guard.yml Enforces "PRs to main come from staging" ├── pr-zone-check.yml Warns on PRs spanning multiple CODEOWNERS zones ├── label-consistency.yml Fails when the zone or area list drifts (runs check-labels.mjs) @@ -161,7 +166,7 @@ Misty/ └── blocked-ready-automation.yml Syncs blocked/ready issue labels ``` -Each service is self-contained: its own tests, its own docs, and its own database *if it needs one* — `llm`, `meeting`, and `connectors` deliberately have none. Dependencies are managed as one uv workspace rooted at this repo's `pyproject.toml`/`uv.lock`, and all six services share one leaf, `packages/auth` (`platform_auth`), for API-key auth — a shared *library* dependency, not a dependency between services, which remain independent of each other. Add a new service by dropping it in `services/` following the same shape (and adding its CI job in the same PR). +Each service is self-contained: its own tests, its own docs, and its own database *if it needs one* — `llm`, `meeting`, and `connectors` deliberately have none. Dependencies are managed as one uv workspace rooted at this repo's `pyproject.toml`/`uv.lock`, and all seven services share one leaf, `packages/auth` (`platform_auth`), for API-key auth — a shared *library* dependency, not a dependency between services, which remain independent of each other. Add a new service by dropping it in `services/` following the same shape (and adding its CI job in the same PR). --- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e36b804..01fd6bc 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -212,6 +212,47 @@ describes how it applies them concretely. driven by an actor supplied via `X-On-Behalf-Of` rather than by the key alone. No other service needs this today. +## Access architecture: two doors, not one gateway + +The platform has exactly two ways in, and they're deliberately asymmetric — there is +**no internal gateway**. Internal services trust each other directly; only external +callers go through a gateway at all. + +- **Internal door.** team-tracking, documentation-system, and the gateway's own + outbound call to team-tracking all authenticate the same way: a per-consumer key, + argon2-hashed, scoped, issued by the target service's own CLI (`team-tracking-keys`, + `doc-keys`). The machinery is the shared [`packages/auth`](../packages/auth) + (`platform_auth`) library described above — each internal service is its own + authority over its own keys. There is no shared internal proxy standing in front of + them; adding a service that trusts another means issuing it a key on that service, + nothing more. +- **External door — [`services/gateway/`](../services/gateway/README.md), implemented.** + Callers outside the org's trust boundary (a GitHub Action, a future third-party + integration) never get a team-tracking key. Instead they hold a key issued by the + gateway's own external registry (`gateway-keys`, scoped e.g. `resolve:discord`), and + the gateway holds exactly **one** internal team-tracking key + (`identifiers:read`) for its own outbound calls. It composes and curates — it never + passes an internal response straight through — and adds the protections an + externally-facing surface needs that internal services don't: a public Railway + domain, per-key rate limiting behind a per-IP flood guard, and audit logging of + every external request. + + The asymmetry runs one level deeper than the key registries. Every internal + service also accepts an env-bootstrap key (`API_KEY`), which `platform_auth` + resolves to the wildcard `admin` scope — the grace path you use to reach the + admin API that issues the first real key, and safe because only the private + network can reach it. **The gateway does not.** It has no admin API to + bootstrap (`gateway-keys` writes to its database directly) and it is the one + service on the public internet, so it passes `get_env_key=lambda: None` and + every caller must present an issued, scoped key. When adding an + externally-reachable service, copy that, not the internal shim. + +The gateway's first (and so far only) endpoint is the resolver, +`GET /v1/resolve/discord/{github_login}` — it turns a GitHub login into the Discord id +linked to the same person in the directory, and nothing else, for #34's reviewer-ping +GitHub Action. New external use cases get new narrow endpoints on the gateway, not +broader access to team-tracking itself. + ## Why the directory is built first The build order — **directory → docs catalog → search** — isn't arbitrary. It follows diff --git a/docs/CODE-OWNERSHIP.md b/docs/CODE-OWNERSHIP.md index 068618f..3693cda 100644 --- a/docs/CODE-OWNERSHIP.md +++ b/docs/CODE-OWNERSHIP.md @@ -24,6 +24,7 @@ Fourteen buckets. Every tracked file lands in exactly one. | `packages/other` | `packages/*` | `/packages/` | @qiuethan | | `services/connectors` | `services/connectors/*` | `/services/connectors/` | @qiuethan | | `services/documentation-system` | `services/documentation-system/*` | `/services/documentation-system/` | @qiuethan | +| `services/gateway` | `services/gateway/*` | `/services/gateway/` | @qiuethan | | `services/llm` | `services/llm/*` | `/services/llm/` | @qiuethan | | `services/meeting` | `services/meeting/*` | `/services/meeting/` | @qiuethan | | `services/team-tracking` | `services/team-tracking/*` | `/services/team-tracking/` | @qiuethan | diff --git a/services/gateway/.dockerignore b/services/gateway/.dockerignore new file mode 100644 index 0000000..c579947 --- /dev/null +++ b/services/gateway/.dockerignore @@ -0,0 +1,5 @@ +.venv +__pycache__ +*.pyc +.env +.pytest_cache diff --git a/services/gateway/.env.example b/services/gateway/.env.example new file mode 100644 index 0000000..a521621 --- /dev/null +++ b/services/gateway/.env.example @@ -0,0 +1,16 @@ +DATABASE_URL=postgresql+psycopg://gateway:dev_password@localhost:5435/gateway + +# No API_KEY. Unlike the internal services, the gateway has no env-bootstrap +# admin key — inbound callers must present a scoped key issued by `gateway-keys` +# and stored in the api_keys table. See src/api/auth.py. + +# Outbound: the gateway's own team-tracking key, scoped identifiers:read. +DIRECTORY_BASE_URL=http://localhost:8000 +DIRECTORY_API_KEY=dev-api-key-change-me + +GATEWAY_ENV=local + +# Set to true wherever a proxy terminates TLS in front of the gateway (Railway +# does). Off by default so a directly-exposed deploy can't be handed a spoofed +# X-Forwarded-For and slip the per-IP rate limit. +TRUST_PROXY_HEADERS=false diff --git a/services/gateway/Dockerfile b/services/gateway/Dockerfile new file mode 100644 index 0000000..f1bbf5f --- /dev/null +++ b/services/gateway/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.11-slim +COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv +WORKDIR /app +COPY pyproject.toml uv.lock ./ +COPY packages/ ./packages/ +COPY services/gateway/ ./services/gateway/ +RUN uv sync --frozen --no-dev --package gateway +ENV PATH="/app/.venv/bin:$PATH" +WORKDIR /app/services/gateway +EXPOSE 8000 +CMD ["uvicorn", "src.api.app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/services/gateway/README.md b/services/gateway/README.md new file mode 100644 index 0000000..bbcd0d7 --- /dev/null +++ b/services/gateway/README.md @@ -0,0 +1,248 @@ +# gateway + +The **public** external API gateway — a thin, scoped, rate-limited door onto UTMIST's +internal directory for third-party consumers (GitHub Actions, external integrations) +that should never see the internal `team-tracking` API directly. + +## What this service does + +team-tracking is the internal source of truth for the org, but not every consumer of +that data is inside the org's trust boundary. A GitHub Action, for instance, needs to +turn a GitHub login into a Discord id (to @-mention a reviewer) without holding a +team-tracking key or seeing anything else in the directory. + +The gateway exists for exactly that shape of consumer: + +- It holds **one internal team-tracking key** (scoped `identifiers:read`) for its own + outbound calls — external callers never see it. +- It issues and manages its **own, separate registry of external API keys** (via the + `gateway-keys` CLI), scoped to gateway-specific permissions like `resolve:discord`. +- It exposes a **narrow, curated surface** — today, one endpoint — that returns only + what the consumer needs (a Discord id), never a raw pass-through of team-tracking's + response. +- It rate-limits and audit-logs every request, since (unlike the internal services) + its callers are outside UTMIST's control. + +This is the "external door" half of the [access architecture](../../docs/ARCHITECTURE.md): +internal services trust each other via the shared `packages/auth` library and their own +keys; anything reaching in from outside the org goes through the gateway instead. + +## Quick start + +Prerequisites: Docker, Python 3.11+, [uv](https://github.com/astral-sh/uv). + +```bash +# 0. From the repo root, enter the service directory (all commands below run here) +cd services/gateway + +# 1. Copy environment config and start Postgres +cp .env.example .env +docker compose up -d postgres + +# 2. Install dependencies (including dev tools) +uv sync --extra dev + +# 3. Apply database migrations (creates the api_keys table) +uv run alembic upgrade head + +# 4. Start the API server +uv run uvicorn src.api.app:app --reload --port 8006 +``` + +> The repo is a single [uv workspace](https://docs.astral.sh/uv/concepts/workspaces/) (root `pyproject.toml` with `[tool.uv.workspace] members = ["services/*", "packages/*"]`, one root `uv.lock`). gateway depends on the shared `platform-auth` package (`[tool.uv.sources] platform-auth = { workspace = true }`) but the commands above are unchanged — `uv sync`, `uv run pytest`, `uv run alembic` still work exactly as shown when run from this directory. + +The API is now at `http://localhost:8006` (8000–8005 are the internal services). +Interactive Swagger UI is at `http://localhost:8006/docs`; the machine-readable +schema is at `http://localhost:8006/openapi.json`. + +### Configuration (`.env`) + +| Variable | Purpose | +|---|---| +| `DATABASE_URL` | The gateway's **own** Postgres — it holds only `api_keys` (its external key registry), never a copy of team-tracking's data. | +| `DIRECTORY_BASE_URL` | team-tracking's base URL — where the gateway makes its **outbound** call. | +| `DIRECTORY_API_KEY` | The gateway's **one internal** team-tracking key, scoped `identifiers:read`. Issued on team-tracking via `team-tracking-keys issue --name gateway --scopes identifiers:read`. | +| `GATEWAY_ENV` | `local` / `staging` / `production`. In non-`local` envs, startup refuses to boot if `DIRECTORY_API_KEY` is still the built-in dev default. | +| `TRUST_PROXY_HEADERS` | Whether a proxy in front of the gateway sets `X-Forwarded-For` / `X-Real-IP`. **Set this to `true` on Railway.** Left `false`, the per-IP flood guard buckets every caller behind the proxy together; set `true` where no proxy exists, and a caller can forge their own source address. | + +**There is deliberately no `API_KEY`.** Every other service in this repo carries +one to feed `platform_auth`'s env-bootstrap path, which authenticates you as a +key holding `admin` — a wildcard scope. On the internal services that is a +sanctioned grace path, reachable only from the private network, and it is how +you authenticate to the admin API that issues the first real key. The gateway +has neither justification: its keys are issued straight into the database by +`gateway-keys`, and it is the only service exposed to the internet. Setting +`API_KEY` here does nothing — `src/api/auth.py` passes `get_env_key=lambda: +None`, and `tests/test_auth.py` pins that it stays that way. + +## The resolver endpoint + +``` +GET /v1/resolve/discord/{github_login} +``` + +Resolves a GitHub login to the Discord id linked to the same person in team-tracking's +directory. Requires `X-API-Key` on a key scoped `resolve:discord`. + +**Response — `200 OK`:** + +```json +{ "discord_id": "123456789012345678" } +``` + +Only the Discord id is returned — no name, no team, no other identifiers. That's the +whole point of the curated surface: the gateway composes two internal calls +(`get_person_by_github` → `list_identifiers`) and hands back exactly one field. + +**Error responses:** + +| Status | Meaning | +|---|---| +| `401` | Missing or invalid `X-API-Key`. | +| `403` | Key valid but lacks the `resolve:discord` scope. | +| `404` | No Discord id for that GitHub login. | +| `429` | Rate limit exceeded — either the caller's key quota or the per-IP flood guard (see below). | +| `503` | team-tracking (the internal directory) is unreachable, or answered in a shape we don't recognise — the gateway doesn't guess; it fails closed. | + +The `404` is deliberately one message for two different situations: "that login +isn't in the directory" and "it is, but there's no Discord account linked". Told +apart, they let anyone holding a `resolve:discord` key feed in a list of GitHub +logins and learn which of them are UTMIST members — a membership oracle on a +public endpoint. The distinction is still visible in the audit log, where only +we can read it. `tests/test_resolve.py` pins that the two responses are byte-identical. + +### Two things to know about this endpoint + +**The lookup is case-sensitive, and GitHub logins are not.** team-tracking +matches `person_identifiers.external_id` exactly for every provider except +`email`, so a person stored as `octocat` will **not** be found by a request for +`OctoCat`. Whoever links the identifier and whoever calls the endpoint have to +agree on casing. The gateway does not lowercase the login, because that would +only help if stored values were already lowercase and would break the mixed-case +links that currently work. The real fix belongs upstream — normalise `github` +identifiers on write in team-tracking, plus a migration for existing rows — and +is tracked separately. Until then, link GitHub identifiers using the exact login +GitHub reports. + +**The GitHub login appears in the audit log.** It's a path segment, and +`AuditLogMiddleware` records `request.url.path`, so every call writes the login +the caller asked about to stdout. That's intentional: an audit trail for the +public door that omitted *what was requested* would be close to useless for +investigating abuse, and a GitHub login is public, pseudonymous, and supplied by +the caller in the first place. What is never logged is anything the directory +answered back — no person id, no name, no other identifiers, and not the Discord +id itself. + +### Rate limiting + +Two layers, because they defend different things: + +| Layer | Where | Limit | Keyed on | +|---|---|---|---| +| Per-consumer quota | Router dependency, **after** auth | 60 / 60s | `AuthedKey.name` — an issued key | +| Flood guard | Middleware, **in front of** auth | 120 / 60s | Client IP (`/health` exempt) | + +The quota is the one a consumer notices. It runs after `require_api_key`, so it +can only ever be keyed on the name of a key we issued — the set is bounded by +our own key registry, no plaintext key sits in process memory, and nobody +outside can grow it. + +The flood guard exists because authentication is itself the expensive step: a +well-formed `gw_` key forces an argon2 verification, which is slow by design. +Per-key limiting can't bound that — an attacker just varies the key — so +something in front of auth has to. Its limit is loose on purpose; it is a +floodgate, not a quota. + +Both are in-memory and process-local, which is correct for a single replica. A +shared store (Redis) is needed only if the gateway is ever scaled past one. + +## Managing external keys (`gateway-keys`) + +The gateway keeps its own key registry, separate from team-tracking's. Manage it with +the bundled CLI: + +```bash +# Issue an external key for a consumer (e.g. a GitHub Action) +uv run gateway-keys issue --name reviewer-ping --scopes resolve:discord +# Prints: gw__ (shown ONCE — capture it now) + +# List existing keys (metadata only, never plaintext) +uv run gateway-keys list --active-only + +# Revoke a compromised key (soft-delete; history preserved) +uv run gateway-keys revoke +``` + +Scopes recognized today: + +- `resolve:discord` — the only external-facing scope, and the only one to issue. + +`platform_auth` also treats `admin` as a wildcard that satisfies every scope +check. **Never issue a gateway key with it.** There is no bootstrap path that +needs one here (see the note on `API_KEY` above), and on a service reachable +from the internet a wildcard key is a standing invitation. Scope every external +key to exactly the endpoint its consumer calls. + +## Repo layout + +``` +gateway/ +├── contracts/ The domain boundary — no framework imports +│ ├── types.py Pydantic ApiKey type +│ ├── storage.py StorageAdapter Protocol +│ └── directory.py DirectoryClient Protocol + DirectoryUnavailable +│ +├── src/ +│ ├── api/ +│ │ ├── app.py App factory; mounts the resolver router + flood guard + audit middleware +│ │ ├── auth.py Thin shim over `platform_auth`: require_scope, get_actor — env-bootstrap path OFF +│ │ ├── hashing.py Thin shim over `platform_auth`: argon2 key hashing + gw__ generation +│ │ ├── middleware.py Thin shim over `platform_auth`: AuditLogMiddleware +│ │ ├── ratelimit.py Bounded fixed-window counter; per-key quota + per-IP flood guard +│ │ ├── deps.py get_storage() / get_directory() dependencies (pooled directory client) +│ │ └── routers/resolve.py `GET /v1/resolve/discord/{github_login}` +│ │ +│ ├── directory/http_client.py HTTP DirectoryClient — calls team-tracking with DIRECTORY_API_KEY +│ ├── storage/ StorageAdapter implementations (in-memory + Postgres) for the gateway's own api_keys table +│ ├── cli.py gateway-keys CLI (issue / list / revoke external keys) +│ └── config.py Settings (DATABASE_URL, DIRECTORY_*, GATEWAY_ENV, TRUST_PROXY_HEADERS) +│ +├── migrations/ Alembic — 001_api_keys +├── tests/ pytest — auth, cli, config guards, directory client, health, rate limit, resolver, storage +├── Dockerfile, railway.json Production image + Railway config (repo-root Docker context) +└── docker-compose.yml Local Postgres on port 5435 +``` + +## Testing + +```bash +uv run pytest +``` + +Lint and format with ruff: + +```bash +uv run ruff check . +uv run ruff format . +``` + +## Status + +Public gateway with its own `api_keys` registry (migration 001), one internal +team-tracking key for outbound calls, and one endpoint: +`GET /v1/resolve/discord/{github_login}`. Rate-limited (60 req/min/key, plus a +per-IP flood guard) and audit-logged. Every caller must present a key issued by +`gateway-keys` — there is no env-bootstrap admin path. Built on `packages/auth` +(`platform_auth`) — no auth logic is reimplemented here. + +**Not implemented (by design):** + +- **More endpoints** — the gateway only exposes what an external consumer has an + actual need for; new endpoints are added deliberately, not by mirroring + team-tracking's surface. +- **Multi-replica rate limiting** — the in-memory limiter is correct for a single + Railway replica; a shared store (Redis) would be needed to scale horizontally. + +**Known constraint:** GitHub logins resolve case-sensitively, because that is how +team-tracking matches identifiers. See "Two things to know about this endpoint" +above — the fix belongs upstream, not here. diff --git a/services/gateway/alembic.ini b/services/gateway/alembic.ini new file mode 100644 index 0000000..01e994c --- /dev/null +++ b/services/gateway/alembic.ini @@ -0,0 +1,39 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +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/services/gateway/contracts/__init__.py b/services/gateway/contracts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/gateway/contracts/directory.py b/services/gateway/contracts/directory.py new file mode 100644 index 0000000..d391f6c --- /dev/null +++ b/services/gateway/contracts/directory.py @@ -0,0 +1,10 @@ +from typing import Protocol + + +class DirectoryUnavailable(Exception): + """Raised when team-tracking is unreachable or returns 5xx.""" + + +class DirectoryClient(Protocol): + def get_person_by_github(self, github_login: str) -> dict | None: ... + def list_identifiers(self, person_id: str) -> list[dict]: ... diff --git a/services/gateway/contracts/storage.py b/services/gateway/contracts/storage.py new file mode 100644 index 0000000..434504d --- /dev/null +++ b/services/gateway/contracts/storage.py @@ -0,0 +1,15 @@ +from typing import Protocol +from uuid import UUID + +from contracts.types import ApiKey + + +class StorageAdapter(Protocol): + def create_api_key( + self, *, name: str, prefix: str, key_hash: str, scopes: list[str], actor: str + ) -> ApiKey: ... + def get_api_key_by_prefix(self, prefix: str) -> ApiKey | None: ... + def get_api_key_hash(self, prefix: str) -> str | None: ... + def list_api_keys(self, *, active_only: bool = False) -> list[ApiKey]: ... + def revoke_api_key(self, api_key_id: UUID, *, actor: str) -> ApiKey | None: ... + def touch_api_key_last_used(self, api_key_id: UUID) -> None: ... diff --git a/services/gateway/contracts/types.py b/services/gateway/contracts/types.py new file mode 100644 index 0000000..bae3a75 --- /dev/null +++ b/services/gateway/contracts/types.py @@ -0,0 +1,27 @@ +from datetime import datetime +from uuid import UUID + +from pydantic import BaseModel, ConfigDict + + +class ApiKey(BaseModel): + model_config = ConfigDict(extra="forbid") + id: UUID + name: str + prefix: str + scopes: list[str] + active: bool = True + revoked_at: datetime | None = None + last_used_at: datetime | None = None + + +class ApiKeyCreate(BaseModel): + model_config = ConfigDict(extra="forbid") + name: str + scopes: list[str] = [] + + +class IssuedApiKey(BaseModel): + model_config = ConfigDict(extra="forbid") + plaintext: str + api_key: ApiKey diff --git a/services/gateway/docker-compose.yml b/services/gateway/docker-compose.yml new file mode 100644 index 0000000..14a40a7 --- /dev/null +++ b/services/gateway/docker-compose.yml @@ -0,0 +1,8 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_USER: gateway + POSTGRES_PASSWORD: dev_password + POSTGRES_DB: gateway + ports: ["5435:5432"] diff --git a/services/gateway/migrations/env.py b/services/gateway/migrations/env.py new file mode 100644 index 0000000..04e25ea --- /dev/null +++ b/services/gateway/migrations/env.py @@ -0,0 +1,42 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from src.config import get_settings +from src.storage.schema import metadata as target_metadata + +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) + + +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/services/gateway/migrations/script.py.mako b/services/gateway/migrations/script.py.mako new file mode 100644 index 0000000..1101630 --- /dev/null +++ b/services/gateway/migrations/script.py.mako @@ -0,0 +1,28 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + """Upgrade schema.""" + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + """Downgrade schema.""" + ${downgrades if downgrades else "pass"} diff --git a/services/gateway/migrations/versions/001_api_keys.py b/services/gateway/migrations/versions/001_api_keys.py new file mode 100644 index 0000000..14ce8f3 --- /dev/null +++ b/services/gateway/migrations/versions/001_api_keys.py @@ -0,0 +1,51 @@ +"""api_keys table + +Revision ID: 001 +Revises: +Create Date: 2026-07-05 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ARRAY, UUID + +revision = "001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "api_keys", + sa.Column( + "id", UUID(as_uuid=True), primary_key=True, server_default=sa.text("gen_random_uuid()") + ), + sa.Column("name", sa.Text, nullable=False, unique=True), + sa.Column("prefix", sa.Text, nullable=False, unique=True), + sa.Column("key_hash", sa.Text, nullable=False), + sa.Column( + "scopes", ARRAY(sa.Text), nullable=False, server_default=sa.text("ARRAY[]::text[]") + ), + sa.Column("active", sa.Boolean, nullable=False, server_default=sa.text("true")), + sa.Column( + "created_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column( + "updated_at", + sa.DateTime(timezone=True), + nullable=False, + server_default=sa.text("now()"), + ), + sa.Column("created_by", sa.Text, nullable=False), + sa.Column("updated_by", sa.Text, nullable=False), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("last_used_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_table("api_keys") diff --git a/services/gateway/pyproject.toml b/services/gateway/pyproject.toml new file mode 100644 index 0000000..f1a8de6 --- /dev/null +++ b/services/gateway/pyproject.toml @@ -0,0 +1,44 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["src"] + +[tool.uv] +package = true + +[project] +name = "gateway" +version = "0.1.0" +description = "UTMIST external API gateway" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.115", + "pydantic>=2.9", + "pydantic-settings>=2.5", + "sqlalchemy>=2.0.35", + "psycopg[binary]>=3.2", + "alembic>=1.13", + "uvicorn[standard]>=0.32", + "argon2-cffi>=23.1", + "httpx>=0.27", + "platform-auth", +] + +[tool.uv.sources] +platform-auth = { workspace = true } + +[project.optional-dependencies] +dev = ["pytest>=8.3", "httpx>=0.27", "ruff>=0.6"] + +[project.scripts] +gateway-keys = "src.cli:main" + +[tool.pytest.ini_options] +testpaths = ["tests"] +pythonpath = [".", "tests"] + +[tool.ruff] +line-length = 100 +target-version = "py311" diff --git a/services/gateway/railway.json b/services/gateway/railway.json new file mode 100644 index 0000000..f3255b3 --- /dev/null +++ b/services/gateway/railway.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://railway.com/railway.schema.json", + "build": { "builder": "DOCKERFILE", "dockerfilePath": "services/gateway/Dockerfile" }, + "deploy": { + "startCommand": "sh -c 'uvicorn src.api.app:app --host 0.0.0.0 --port ${PORT}'", + "preDeployCommand": "alembic upgrade head", + "healthcheckPath": "/health", + "restartPolicyType": "ON_FAILURE" + } +} diff --git a/services/gateway/src/__init__.py b/services/gateway/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/gateway/src/api/__init__.py b/services/gateway/src/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/gateway/src/api/app.py b/services/gateway/src/api/app.py new file mode 100644 index 0000000..563675e --- /dev/null +++ b/services/gateway/src/api/app.py @@ -0,0 +1,50 @@ +import logging + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse + +from contracts.directory import DirectoryUnavailable +from src.api.middleware import AuditLogMiddleware +from src.api.ratelimit import ClientRateLimitMiddleware +from src.api.routers import resolve +from src.config import get_settings, verify_production_secrets + +logger = logging.getLogger("gateway") + + +def create_app() -> FastAPI: + verify_production_secrets() + app = FastAPI( + title="UTMIST gateway", + version="0.1.0", + description="External API gateway.", + docs_url="/docs", + ) + # Order matters, and add_middleware prepends: the last one added is the + # outermost. Audit must be outermost so it still records the requests the + # flood guard short-circuits — a 429 storm is exactly what you want in the + # log. The per-key quota is not here; it runs as a router dependency, after + # auth has resolved the key (see src/api/ratelimit.py). + app.add_middleware( + ClientRateLimitMiddleware, + trust_proxy=get_settings().trust_proxy_headers, + ) + app.add_middleware(AuditLogMiddleware, logger_name="gateway.audit") + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + app.include_router(resolve.router) + + @app.exception_handler(DirectoryUnavailable) + async def _directory_unavailable(request: Request, exc: DirectoryUnavailable): + logger.warning("directory unavailable: %s", exc) + return JSONResponse( + status_code=503, content={"detail": "directory temporarily unavailable"} + ) + + return app + + +app = create_app() diff --git a/services/gateway/src/api/auth.py b/services/gateway/src/api/auth.py new file mode 100644 index 0000000..79a71d8 --- /dev/null +++ b/services/gateway/src/api/auth.py @@ -0,0 +1,29 @@ +"""Thin shim: builds the gateway's auth deps from platform_auth (external keys).""" + +from platform_auth import AuthedKey, build_auth # noqa: F401 + +from src.api.deps import get_storage + +_deps = build_auth( + get_storage, + envelope="gw_", + # No env-bootstrap key, unlike every other service's shim. That path mints + # an AuthedKey carrying ADMIN_SCOPE, and ADMIN_SCOPE is a wildcard — + # AuthedKey.has_scope() returns True for every scope once it is present. On + # the internal services that is a deliberate grace path: it is how you + # authenticate to the admin API that issues the first real key, and it is + # only reachable from the private network. + # + # The gateway has neither half of that justification. Its keys are issued + # direct-to-DB by the gateway-keys CLI, so there is no admin API to + # bootstrap; and it is the one service exposed to the internet, so the env + # key would be a single wildcard credential on the public door. Returning + # None disables the path outright: every caller must present a scoped key + # from the api_keys table. tests/test_auth.py pins this. + get_env_key=lambda: None, + audit_logger_name="gateway.audit", +) + +require_api_key = _deps.require_api_key +require_scope = _deps.require_scope +get_actor = _deps.get_actor diff --git a/services/gateway/src/api/deps.py b/services/gateway/src/api/deps.py new file mode 100644 index 0000000..fe244d3 --- /dev/null +++ b/services/gateway/src/api/deps.py @@ -0,0 +1,37 @@ +from functools import lru_cache + +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine + +from contracts.directory import DirectoryClient +from contracts.storage import StorageAdapter +from src.config import get_settings + + +@lru_cache(maxsize=1) +def _default_engine() -> Engine: + return create_engine(get_settings().database_url, future=True, pool_pre_ping=True) + + +@lru_cache(maxsize=1) +def _default_directory() -> DirectoryClient: + from src.directory.http_client import HttpDirectoryClient + + s = get_settings() + # .get_secret_value() at the boundary: HttpDirectoryClient puts this + # straight into an outbound header, which needs the raw str. This is one of + # the two sanctioned unwrap sites (the other is verify_production_secrets); + # everywhere else the field stays wrapped. + return HttpDirectoryClient(s.directory_base_url, s.directory_api_key.get_secret_value()) + + +def get_storage() -> StorageAdapter: + from src.storage.postgres import PostgresStorageAdapter + + return PostgresStorageAdapter(_default_engine()) + + +def get_directory() -> DirectoryClient: + # Cached, not per-request: the client owns a connection pool (see + # HttpDirectoryClient), which is worthless if it is rebuilt every request. + return _default_directory() diff --git a/services/gateway/src/api/hashing.py b/services/gateway/src/api/hashing.py new file mode 100644 index 0000000..9aa4663 --- /dev/null +++ b/services/gateway/src/api/hashing.py @@ -0,0 +1,15 @@ +"""Thin shim over platform_auth, binding the gateway key envelope.""" + +from platform_auth import PREFIX_LENGTH, verify_key # noqa: F401 +from platform_auth import generate_key as _generate_key +from platform_auth import parse_prefix as _parse_prefix + +KEY_ENVELOPE = "gw_" + + +def generate_key() -> tuple[str, str, str]: + return _generate_key(KEY_ENVELOPE) + + +def parse_prefix(candidate: str) -> str | None: + return _parse_prefix(candidate, KEY_ENVELOPE) diff --git a/services/gateway/src/api/middleware.py b/services/gateway/src/api/middleware.py new file mode 100644 index 0000000..ef1de7d --- /dev/null +++ b/services/gateway/src/api/middleware.py @@ -0,0 +1,3 @@ +"""Thin shim re-exporting the shared audit middleware.""" + +from platform_auth import AuditLogMiddleware # noqa: F401 diff --git a/services/gateway/src/api/ratelimit.py b/services/gateway/src/api/ratelimit.py new file mode 100644 index 0000000..fbf25a8 --- /dev/null +++ b/services/gateway/src/api/ratelimit.py @@ -0,0 +1,168 @@ +"""Rate limiting for the public door. + +Two layers, because they defend different things. + +`enforce_key_rate_limit` is the real per-consumer quota. It runs as a FastAPI +dependency *after* platform_auth has resolved the key, and counts against +`AuthedKey.name` — a value that can only have come from a row in `api_keys`. +That matters as much for the bookkeeping as for the quota: the set of names is +bounded by the number of keys we have issued, so nobody on the internet can grow +the counter, and no plaintext key is held in process memory. + +`ClientRateLimitMiddleware` sits in front of auth and counts per client IP. +Authentication is itself the expensive step — a well-formed `gw_` key forces an +argon2 verification, which is deliberately slow — so something has to bound how +fast an unauthenticated caller can demand one, and per-key limiting cannot: an +attacker simply varies the key. Its limit is loose on purpose. It is a +floodgate, not a quota. + +Both sit on FixedWindowCounter, which is bounded by construction. The first +version of this module keyed an unbounded dict on the raw `X-API-Key` header, in +front of auth: anyone could grow it without limit inside a window, and every +request past 1024 entries then paid a full O(n) scan (twice). + +In-memory and process-local, which is correct because the gateway runs a single +replica. A shared store (Redis) is needed only if it is ever scaled past one. +""" + +import time +from collections import OrderedDict + +from fastapi import Depends, HTTPException, status +from platform_auth import AuthedKey +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse + +from src.api.auth import require_api_key + +_TOO_MANY = "rate limit exceeded" + +# Per issued key. The quota an external consumer actually gets. +KEY_LIMIT = 60 +KEY_WINDOW_S = 60.0 + +# Per client IP, in front of auth. Set well above the per-key quota: this is not +# trying to meter anyone, only to cap how much argon2 work one address can +# demand. Note that with TRUST_PROXY_HEADERS unset behind a proxy, every caller +# shares one bucket — another reason to keep it loose. +IP_LIMIT = 120 +IP_WINDOW_S = 60.0 + + +class FixedWindowCounter: + """Per-identity fixed-window counter with a hard capacity. + + The capacity is the point. `hit()` takes an identity supplied — directly or + indirectly — by the caller, so an unbounded dict is a memory-growth vector + for anyone who can vary it. Entries are held in insertion order and an entry + is re-inserted whenever its window restarts, so the front of the dict is + always the oldest window: evicting from the front drops the entry closest to + expiring, which is what a scan for expired entries would have picked anyway, + at O(1) instead of O(n). + + Eviction under pressure is deliberately permissive — a flood of fresh + identities can push a legitimate one out and hand it a fresh window. That + trade is on purpose: the alternative, refusing new identities once full, + would let an attacker lock everyone else out, turning a rate limiter into a + denial-of-service tool. + """ + + def __init__(self, *, limit: int, window_s: float, capacity: int = 8192) -> None: + self._limit = limit + self._window = window_s + self._capacity = capacity + self._hits: OrderedDict[str, tuple[int, float]] = OrderedDict() + + def hit(self, identity: str, now: float | None = None) -> bool: + """Record a request for `identity`. False means it is over the limit.""" + now = time.monotonic() if now is None else now + entry = self._hits.get(identity) + if entry is not None: + count, start = entry + if now - start < self._window: + self._hits[identity] = (count + 1, start) + return count + 1 <= self._limit + # Window elapsed. Drop it so the re-insert below puts it at the + # back, keeping the dict ordered by window start. + del self._hits[identity] + while len(self._hits) >= self._capacity: + self._hits.popitem(last=False) + self._hits[identity] = (1, now) + return 1 <= self._limit + + def clear(self) -> None: + self._hits.clear() + + def __len__(self) -> int: + return len(self._hits) + + +def client_identity(request: Request, *, trust_proxy: bool) -> str: + """Best available identifier for the caller, for per-IP limiting. + + With `trust_proxy`, take the *rightmost* X-Forwarded-For entry. A proxy + appends the address it actually saw, so the rightmost hop is the one value + in that header the client could not have written itself. Taking the leftmost + — the usual "original client" reading — would let anyone reset their own + bucket by sending a fresh X-Forwarded-For on every request, which defeats + the whole layer. + + Without `trust_proxy` the headers are ignored entirely and the socket peer + is used. Behind an unacknowledged proxy that collapses every caller into one + bucket, which is why IP_LIMIT is set loose enough not to bite a real + consumer. + """ + if trust_proxy: + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + hops = [hop.strip() for hop in forwarded.split(",") if hop.strip()] + if hops: + return hops[-1] + real_ip = request.headers.get("X-Real-IP") + if real_ip and real_ip.strip(): + return real_ip.strip() + return request.client.host if request.client else "unknown" + + +class ClientRateLimitMiddleware(BaseHTTPMiddleware): + """Per-IP flood guard, mounted in front of auth. See the module docstring.""" + + def __init__( + self, + app, + *, + limit: int = IP_LIMIT, + window_s: float = IP_WINDOW_S, + trust_proxy: bool = False, + capacity: int = 8192, + ) -> None: + super().__init__(app) + self._counter = FixedWindowCounter(limit=limit, window_s=window_s, capacity=capacity) + self._trust_proxy = trust_proxy + + async def dispatch(self, request: Request, call_next): + # /health is exempt: it is unauthenticated, costs nothing, and Railway's + # liveness probe hits it on a fixed interval. Letting the flood guard + # 429 the probe would restart a service that is answering fine. + if request.url.path == "/health": + return await call_next(request) + if not self._counter.hit(client_identity(request, trust_proxy=self._trust_proxy)): + return JSONResponse(status_code=429, content={"detail": _TOO_MANY}) + return await call_next(request) + + +def build_key_rate_limit(counter: FixedWindowCounter): + """FastAPI dependency enforcing `counter` against the authenticated key.""" + + def _dep(key: AuthedKey = Depends(require_api_key)) -> AuthedKey: + if not counter.hit(key.name): + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail=_TOO_MANY) + return key + + return _dep + + +# The process-wide per-key quota, mounted on the /v1 router (see routers/resolve.py). +key_rate_limit_counter = FixedWindowCounter(limit=KEY_LIMIT, window_s=KEY_WINDOW_S) +enforce_key_rate_limit = build_key_rate_limit(key_rate_limit_counter) diff --git a/services/gateway/src/api/routers/__init__.py b/services/gateway/src/api/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/gateway/src/api/routers/resolve.py b/services/gateway/src/api/routers/resolve.py new file mode 100644 index 0000000..e893bc3 --- /dev/null +++ b/services/gateway/src/api/routers/resolve.py @@ -0,0 +1,62 @@ +from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel, ConfigDict + +from contracts.directory import DirectoryClient, DirectoryUnavailable +from src.api.auth import AuthedKey, require_scope +from src.api.deps import get_directory +from src.api.ratelimit import enforce_key_rate_limit + +# One body for both misses. Splitting them — "github login not found" versus +# "no discord identifier for that github login" — told an external caller +# whether a given GitHub user is in the directory at all. That is a membership +# oracle on a public endpoint, and enumerating it is cheap: exactly the kind of +# leak this service exists to prevent. The two cases stay distinguishable in the +# audit log, where only we can read them. +_NOT_FOUND = "no discord id for that github login" + + +class DiscordId(BaseModel): + """The entire public response. Nothing else about the person leaves here.""" + + model_config = ConfigDict(extra="forbid") + discord_id: str + + +router = APIRouter( + prefix="/v1", + tags=["resolve"], + # The per-consumer quota, applied to every /v1 route. It runs after + # require_api_key resolves the caller, so it meters an issued key rather + # than an attacker-supplied header; see src/api/ratelimit.py. Wrong-scope + # requests are metered too — they are still requests we had to authenticate. + dependencies=[Depends(enforce_key_rate_limit)], +) + + +@router.get("/resolve/discord/{github_login}", response_model=DiscordId) +def resolve_discord( + github_login: str, + directory: DirectoryClient = Depends(get_directory), + _: AuthedKey = Depends(require_scope("resolve:discord")), +) -> DiscordId: + # `github_login` reaches the audit log, because it is a path segment and + # AuditLogMiddleware records request.url.path. That is deliberate: it is + # what makes abuse of the public door investigable, the value is public and + # pseudonymous, and the caller supplied it in the first place. What never + # joins it there is anything the directory told us back. + person = directory.get_person_by_github(github_login) + if person is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=_NOT_FOUND) + person_id = person.get("id") + if person_id is None: + # The directory answered, but not in a shape we understand. That is an + # upstream fault rather than a missing record, so fail closed to 503 + # instead of raising KeyError into a 500. + raise DirectoryUnavailable("person record has no id") + identifiers = directory.list_identifiers(person_id) + discord_id = next( + (i.get("external_id") for i in identifiers if i.get("provider") == "discord"), None + ) + if discord_id is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=_NOT_FOUND) + return DiscordId(discord_id=discord_id) diff --git a/services/gateway/src/cli.py b/services/gateway/src/cli.py new file mode 100644 index 0000000..cb54243 --- /dev/null +++ b/services/gateway/src/cli.py @@ -0,0 +1,99 @@ +"""gateway-keys — direct-to-DB management of the gateway's EXTERNAL API keys. + + gateway-keys issue --name --scopes resolve:discord + gateway-keys list [--active-only] + gateway-keys revoke + +`issue` PRINTS THE PLAINTEXT KEY ONCE to stdout; the argon2 hash is all that's stored. +""" + +import argparse +import sys +from uuid import UUID + +from sqlalchemy import create_engine + +from src.api.hashing import generate_key +from src.config import get_settings +from src.storage.postgres import PostgresStorageAdapter + + +def _adapter() -> PostgresStorageAdapter: + return PostgresStorageAdapter(create_engine(get_settings().database_url, future=True)) + + +def cmd_issue(args: argparse.Namespace) -> int: + plaintext, prefix, key_hash = generate_key() + try: + key = _adapter().create_api_key( + name=args.name, + prefix=prefix, + key_hash=key_hash, + scopes=list(args.scopes or []), + actor=args.actor, + ) + except ValueError as e: + # Almost always a duplicate --name. Matching cmd_revoke, an operator + # error gets a sentence and an exit code, not a traceback. + print(f"error: {e}", file=sys.stderr) + return 1 + print("=" * 70, file=sys.stderr) + print("EXTERNAL API KEY ISSUED (shown once)", file=sys.stderr) + print(f" Name: {key.name}", file=sys.stderr) + print(f" Scopes: {', '.join(key.scopes) if key.scopes else '(none)'}", file=sys.stderr) + print(f" Key id: {key.id}", file=sys.stderr) + print(plaintext) + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + keys = _adapter().list_api_keys(active_only=args.active_only) + if not keys: + print("(no keys)", file=sys.stderr) + return 0 + for k in keys: + active = "yes" if (k.active and k.revoked_at is None) else "no" + print(f"{k.name:<25} {k.prefix:<10} {active:<7} {', '.join(k.scopes) or '(none)'}") + return 0 + + +def cmd_revoke(args: argparse.Namespace) -> int: + try: + key_id = UUID(args.api_key_id) + except ValueError: + print(f"error: not a valid UUID: {args.api_key_id}", file=sys.stderr) + return 2 + revoked = _adapter().revoke_api_key(key_id, actor=args.actor) + if revoked is None: + print(f"error: no such api key: {key_id}", file=sys.stderr) + return 1 + print(f"revoked {revoked.name} ({revoked.prefix})", file=sys.stderr) + return 0 + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="gateway-keys", description="Manage gateway external API keys." + ) + p.add_argument("--actor", default="cli") + subs = p.add_subparsers(dest="cmd", required=True) + pi = subs.add_parser("issue") + pi.add_argument("--name", required=True) + pi.add_argument("--scopes", nargs="*", default=[]) + pi.set_defaults(func=cmd_issue) + pl = subs.add_parser("list") + pl.add_argument("--active-only", action="store_true") + pl.set_defaults(func=cmd_list) + pr = subs.add_parser("revoke") + pr.add_argument("api_key_id") + pr.set_defaults(func=cmd_revoke) + return p + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/services/gateway/src/config.py b/services/gateway/src/config.py new file mode 100644 index 0000000..a48e38f --- /dev/null +++ b/services/gateway/src/config.py @@ -0,0 +1,68 @@ +from functools import lru_cache +from typing import Literal + +from pydantic import SecretStr +from pydantic_settings import BaseSettings, SettingsConfigDict + +# The built-in dev secret for the gateway's OUTBOUND team-tracking key. Only +# acceptable when gateway_env == "local"; any other environment must override +# DIRECTORY_API_KEY with the real issued key (see verify_production_secrets). +DEFAULT_DEV_API_KEY = "dev-api-key-change-me" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=".env", extra="ignore") + + database_url: str = "postgresql+psycopg://gateway:dev_password@localhost:5435/gateway" + + # NOTE: there is deliberately no inbound `api_key` here. Every other service + # carries one to feed platform_auth's env-bootstrap path; the gateway does + # not, because that path grants ADMIN_SCOPE and this is the only service + # reachable from the internet. See src/api/auth.py for the full reasoning. + + # Outbound: the gateway's own team-tracking key (scoped identifiers:read). + # SecretStr, not str: a plain str field prints in full on any repr/diff/ + # traceback, and this is a live credential for the private directory. + # SecretStr makes that structurally impossible — repr/str always render + # "**********" — so don't revert this to str. Only the two boundaries that + # must see the raw value (verify_production_secrets below, and + # src/api/deps.py building the outbound header) call .get_secret_value(). + directory_base_url: str = "http://localhost:8000" + directory_api_key: SecretStr = SecretStr(DEFAULT_DEV_API_KEY) + + gateway_env: Literal["local", "staging", "production"] = "local" + + # Whether an upstream proxy sets X-Forwarded-For / X-Real-IP. Off by default + # so a direct deploy can't be fed a spoofed client IP; the Railway deploy + # sets TRUST_PROXY_HEADERS=true. Read by the per-IP rate limiter, which is + # only as good as its notion of "who is calling" (see src/api/ratelimit.py). + trust_proxy_headers: bool = False + + +@lru_cache(maxsize=1) +def get_settings() -> Settings: + return Settings() + + +def verify_production_secrets(settings: Settings | None = None) -> None: + """Fail fast if a non-local environment is still using built-in dev secrets. + + Called from create_app(), so a misconfigured deploy dies at startup rather + than on the first request that needs the directory. + """ + settings = settings or get_settings() + if settings.gateway_env == "local": + return + insecure: list[str] = [] + # .get_secret_value() is required: SecretStr never compares equal to a str, + # so `settings.directory_api_key == DEFAULT_DEV_API_KEY` would silently be + # False forever and this guard would stop firing without any test noticing. + # tests/test_config.py pins exactly that. + if settings.directory_api_key.get_secret_value() == DEFAULT_DEV_API_KEY: + insecure.append("DIRECTORY_API_KEY") + if insecure: + raise RuntimeError( + f"Refusing to start in gateway_env={settings.gateway_env!r}: " + f"{', '.join(insecure)} still set to the built-in dev default. " + "Set a strong, unique value via environment variables." + ) diff --git a/services/gateway/src/directory/__init__.py b/services/gateway/src/directory/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/gateway/src/directory/http_client.py b/services/gateway/src/directory/http_client.py new file mode 100644 index 0000000..ae4bdef --- /dev/null +++ b/services/gateway/src/directory/http_client.py @@ -0,0 +1,44 @@ +from urllib.parse import quote + +import httpx + +from contracts.directory import DirectoryUnavailable + +_TIMEOUT = httpx.Timeout(5.0) + + +class HttpDirectoryClient: + """Looks up people and identifiers over team-tracking's HTTP API. A 404 + means 'no such record' (returns None); connection failure or 5xx means + 'directory unavailable' (raises DirectoryUnavailable). + + Holds one pooled httpx.Client for its whole lifetime. The resolver makes two + directory calls per request, so a client built (and closed) per call would + mean two fresh TCP + TLS handshakes on the hot path. Construct this once — + src/api/deps.py caches the instance — rather than per request. + """ + + def __init__(self, base_url: str, api_key: str, client: httpx.Client | None = None) -> None: + self._base_url = base_url.rstrip("/") + # Set once as a default header so the key never has to be rebuilt (or + # accidentally logged) per call. It is never read back out. + self._client = client or httpx.Client(timeout=_TIMEOUT) + self._client.headers["X-API-Key"] = api_key + + def _get(self, path: str): + try: + resp = self._client.get(f"{self._base_url}{path}") + except httpx.HTTPError as e: + raise DirectoryUnavailable(f"directory unreachable: {e}") from e + if resp.status_code == 404: + return None + if not (200 <= resp.status_code < 300): + raise DirectoryUnavailable(f"directory returned {resp.status_code}") + return resp.json() + + def get_person_by_github(self, github_login: str) -> dict | None: + return self._get(f"/people/by-identifier/github/{quote(github_login, safe='')}") + + def list_identifiers(self, person_id: str) -> list[dict]: + result = self._get(f"/people/{person_id}/identifiers") + return result or [] diff --git a/services/gateway/src/storage/__init__.py b/services/gateway/src/storage/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services/gateway/src/storage/in_memory.py b/services/gateway/src/storage/in_memory.py new file mode 100644 index 0000000..bfe852b --- /dev/null +++ b/services/gateway/src/storage/in_memory.py @@ -0,0 +1,57 @@ +from datetime import datetime, timezone +from uuid import UUID, uuid4 + +from contracts.types import ApiKey + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +class InMemoryStorageAdapter: + """In-process adapter for tests. Not persistent, not thread-safe.""" + + def __init__(self) -> None: + self._api_keys: dict[UUID, ApiKey] = {} + self._hashes: dict[UUID, str] = {} + + def create_api_key( + self, *, name: str, prefix: str, key_hash: str, scopes: list[str], actor: str + ) -> ApiKey: + if any(k.name == name for k in self._api_keys.values()): + raise ValueError(f"api key name already exists: {name}") + if any(k.prefix == prefix for k in self._api_keys.values()): + raise ValueError(f"api key prefix already exists: {prefix}") + key = ApiKey(id=uuid4(), name=name, prefix=prefix, scopes=list(scopes), active=True) + self._api_keys[key.id] = key + self._hashes[key.id] = key_hash + return key + + def _by_prefix(self, prefix: str) -> ApiKey | None: + return next((k for k in self._api_keys.values() if k.prefix == prefix), None) + + def get_api_key_by_prefix(self, prefix: str) -> ApiKey | None: + return self._by_prefix(prefix) + + def get_api_key_hash(self, prefix: str) -> str | None: + row = self._by_prefix(prefix) + return self._hashes.get(row.id) if row else None + + def list_api_keys(self, *, active_only: bool = False) -> list[ApiKey]: + keys = list(self._api_keys.values()) + if active_only: + keys = [k for k in keys if k.active and k.revoked_at is None] + return keys + + def revoke_api_key(self, api_key_id: UUID, *, actor: str) -> ApiKey | None: + key = self._api_keys.get(api_key_id) + if key is None: + return None + updated = key.model_copy(update={"active": False, "revoked_at": _now()}) + self._api_keys[api_key_id] = updated + return updated + + def touch_api_key_last_used(self, api_key_id: UUID) -> None: + key = self._api_keys.get(api_key_id) + if key is not None: + self._api_keys[api_key_id] = key.model_copy(update={"last_used_at": _now()}) diff --git a/services/gateway/src/storage/postgres.py b/services/gateway/src/storage/postgres.py new file mode 100644 index 0000000..659d257 --- /dev/null +++ b/services/gateway/src/storage/postgres.py @@ -0,0 +1,120 @@ +import logging +from datetime import datetime, timezone +from uuid import UUID + +from sqlalchemy import insert, select, update +from sqlalchemy.engine import Engine +from sqlalchemy.exc import IntegrityError + +from contracts.types import ApiKey +from src.storage.schema import api_keys + +logger = logging.getLogger("gateway.storage") + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _api_key_row_to_model(row) -> ApiKey: + return ApiKey( + id=row.id, + name=row.name, + prefix=row.prefix, + scopes=list(row.scopes), + active=row.active, + revoked_at=row.revoked_at, + last_used_at=row.last_used_at, + ) + + +class PostgresStorageAdapter: + """Postgres-backed StorageAdapter using SQLAlchemy Core. + + Every method returns Pydantic domain models (never raw rows, and never the + stored `key_hash`). Callers should not need to know this backend exists — + they see StorageAdapter (Protocol). + """ + + def __init__(self, engine: Engine) -> None: + self._engine = engine + + def create_api_key( + self, + *, + name: str, + prefix: str, + key_hash: str, + scopes: list[str], + actor: str, + ) -> ApiKey: + try: + with self._engine.begin() as conn: + row = conn.execute( + insert(api_keys) + .values( + name=name, + prefix=prefix, + key_hash=key_hash, + scopes=scopes, + created_by=actor, + updated_by=actor, + ) + .returning(api_keys) + ).one() + except IntegrityError as e: + raise ValueError(f"name or prefix already exists: {name!r} / {prefix!r}") from e + return _api_key_row_to_model(row) + + def get_api_key_by_prefix(self, prefix: str) -> ApiKey | None: + with self._engine.connect() as conn: + row = conn.execute(select(api_keys).where(api_keys.c.prefix == prefix)).one_or_none() + return _api_key_row_to_model(row) if row else None + + def get_api_key_hash(self, prefix: str) -> str | None: + with self._engine.connect() as conn: + row = conn.execute( + select(api_keys.c.key_hash).where( + api_keys.c.prefix == prefix, + api_keys.c.active.is_(True), + api_keys.c.revoked_at.is_(None), + ) + ).one_or_none() + return row.key_hash if row else None + + def list_api_keys(self, *, active_only: bool = False) -> list[ApiKey]: + stmt = select(api_keys) + if active_only: + stmt = stmt.where( + api_keys.c.active.is_(True), + api_keys.c.revoked_at.is_(None), + ) + with self._engine.connect() as conn: + rows = conn.execute(stmt).all() + return [_api_key_row_to_model(r) for r in rows] + + def revoke_api_key(self, api_key_id: UUID, *, actor: str) -> ApiKey | None: + now = _now() + with self._engine.begin() as conn: + row = conn.execute( + update(api_keys) + .where(api_keys.c.id == api_key_id) + .values(active=False, revoked_at=now, updated_at=now, updated_by=actor) + .returning(api_keys) + ).one_or_none() + return _api_key_row_to_model(row) if row else None + + def touch_api_key_last_used(self, api_key_id: UUID) -> None: + try: + with self._engine.begin() as conn: + conn.execute( + update(api_keys).where(api_keys.c.id == api_key_id).values(last_used_at=_now()) + ) + except Exception: + # Best-effort: a DB blip must not fail the auth path over a + # bookkeeping write. Logged rather than swallowed outright, because + # silence here means last_used_at can go stale indefinitely and the + # only symptom is a key that looks unused while it is in daily use. + logger.warning( + "could not update last_used_at for api key %s", api_key_id, exc_info=True + ) diff --git a/services/gateway/src/storage/schema.py b/services/gateway/src/storage/schema.py new file mode 100644 index 0000000..0b05251 --- /dev/null +++ b/services/gateway/src/storage/schema.py @@ -0,0 +1,27 @@ +"""SQLAlchemy Core Table definitions for the gateway's api_keys store. + +This module defines TABLES, not ORM classes. All queries in +PostgresStorageAdapter use core-style expressions against these tables. +""" + +from sqlalchemy import Boolean, Column, DateTime, MetaData, Table, Text, text +from sqlalchemy.dialects.postgresql import ARRAY, UUID + +metadata = MetaData() + +api_keys = Table( + "api_keys", + metadata, + Column("id", UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")), + Column("name", Text, nullable=False, unique=True), + Column("prefix", Text, nullable=False, unique=True), + Column("key_hash", Text, nullable=False), + Column("scopes", ARRAY(Text), nullable=False, server_default=text("ARRAY[]::text[]")), + Column("active", Boolean, nullable=False, server_default=text("true")), + Column("created_at", DateTime(timezone=True), nullable=False, server_default=text("now()")), + Column("updated_at", DateTime(timezone=True), nullable=False, server_default=text("now()")), + Column("created_by", Text, nullable=False), + Column("updated_by", Text, nullable=False), + Column("revoked_at", DateTime(timezone=True), nullable=True), + Column("last_used_at", DateTime(timezone=True), nullable=True), +) diff --git a/services/gateway/tests/conftest.py b/services/gateway/tests/conftest.py new file mode 100644 index 0000000..b5441cb --- /dev/null +++ b/services/gateway/tests/conftest.py @@ -0,0 +1,16 @@ +import pytest + +from src.api.ratelimit import key_rate_limit_counter + + +@pytest.fixture(autouse=True) +def _reset_key_rate_limit(): + """The per-key quota is process-wide, so it would otherwise carry across tests. + + Every test builds its key with the same name, which means without this they + all share one bucket and the suite starts failing once it grows past + KEY_LIMIT requests — a confusing failure a long way from its cause. + """ + key_rate_limit_counter.clear() + yield + key_rate_limit_counter.clear() diff --git a/services/gateway/tests/test_auth.py b/services/gateway/tests/test_auth.py new file mode 100644 index 0000000..2610a9a --- /dev/null +++ b/services/gateway/tests/test_auth.py @@ -0,0 +1,66 @@ +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient + +from src.api.auth import require_scope +from src.api.deps import get_storage +from src.api.hashing import generate_key +from src.storage.in_memory import InMemoryStorageAdapter + + +def _client_with_key(scopes): + store = InMemoryStorageAdapter() + plaintext, prefix, key_hash = generate_key() + store.create_api_key(name="c", prefix=prefix, key_hash=key_hash, scopes=scopes, actor="t") + app = FastAPI() + + @app.get("/probe") + def probe(_=Depends(require_scope("resolve:discord"))): + return {"ok": True} + + app.dependency_overrides[get_storage] = lambda: store + return TestClient(app), plaintext + + +def test_valid_scope_200(): + client, key = _client_with_key(["resolve:discord"]) + assert client.get("/probe", headers={"X-API-Key": key}).status_code == 200 + + +def test_missing_scope_403_and_no_key_401(): + client, key = _client_with_key(["other:scope"]) + assert client.get("/probe", headers={"X-API-Key": key}).status_code == 403 + assert client.get("/probe").status_code == 401 + + +def test_revoked_key_401(): + store = InMemoryStorageAdapter() + plaintext, prefix, key_hash = generate_key() + row = store.create_api_key( + name="c", prefix=prefix, key_hash=key_hash, scopes=["resolve:discord"], actor="t" + ) + app = FastAPI() + + @app.get("/probe") + def probe(_=Depends(require_scope("resolve:discord"))): + return {"ok": True} + + app.dependency_overrides[get_storage] = lambda: store + client = TestClient(app) + assert client.get("/probe", headers={"X-API-Key": plaintext}).status_code == 200 + store.revoke_api_key(row.id, actor="t") + assert client.get("/probe", headers={"X-API-Key": plaintext}).status_code == 401 + + +def test_no_env_bootstrap_admin_path(): + """The gateway must not honour an env key. See src/api/auth.py. + + platform_auth's bootstrap path hands out ADMIN_SCOPE, which satisfies every + require_scope check. src/api/auth.py disables it by passing + get_env_key=lambda: None, and this pins that: nothing that isn't a live row + in api_keys gets through, including the value other services use as their + env key. Without the guard, `API_KEY=` in the gateway's + environment would be a wildcard credential on the public door. + """ + client, _ = _client_with_key(["resolve:discord"]) + for candidate in ("dev-api-key-change-me", "", "admin", "gw_notarealkey"): + assert client.get("/probe", headers={"X-API-Key": candidate}).status_code == 401 diff --git a/services/gateway/tests/test_cli.py b/services/gateway/tests/test_cli.py new file mode 100644 index 0000000..ef0b602 --- /dev/null +++ b/services/gateway/tests/test_cli.py @@ -0,0 +1,29 @@ +from src import cli +from src.api.hashing import parse_prefix, verify_key +from src.storage.in_memory import InMemoryStorageAdapter + + +def test_issue_mints_verifiable_key(monkeypatch, capsys): + store = InMemoryStorageAdapter() + monkeypatch.setattr(cli, "_adapter", lambda: store) + rc = cli.main(["issue", "--name", "gh-action", "--scopes", "resolve:discord"]) + assert rc == 0 + plaintext = capsys.readouterr().out.strip() + prefix = parse_prefix(plaintext) + assert store.get_api_key_hash(prefix) is not None + assert verify_key(plaintext, store.get_api_key_hash(prefix)) is True + assert store.get_api_key_by_prefix(prefix).scopes == ["resolve:discord"] + + +def test_issue_reports_a_duplicate_name_without_a_traceback(monkeypatch, capsys): + store = InMemoryStorageAdapter() + monkeypatch.setattr(cli, "_adapter", lambda: store) + assert cli.main(["issue", "--name", "gh-action"]) == 0 + capsys.readouterr() + + assert cli.main(["issue", "--name", "gh-action"]) == 1 + captured = capsys.readouterr() + assert "error:" in captured.err + # Nothing on stdout: a caller piping stdout to a secrets store must not be + # handed a key that was never persisted. + assert captured.out.strip() == "" diff --git a/services/gateway/tests/test_config.py b/services/gateway/tests/test_config.py new file mode 100644 index 0000000..f104d7a --- /dev/null +++ b/services/gateway/tests/test_config.py @@ -0,0 +1,58 @@ +import pytest +from pydantic import SecretStr + +from src.config import DEFAULT_DEV_API_KEY, Settings, verify_production_secrets + + +def _settings(**overrides) -> Settings: + # _env_file=None so a developer's local .env can't leak into these + # assertions; every field under test is passed explicitly. + base = { + "gateway_env": "production", + "directory_api_key": SecretStr("a-real-issued-key"), + } + base.update(overrides) + return Settings(_env_file=None, **base) + + +def test_no_inbound_api_key_field(): + """The gateway must not carry an env-bootstrap key at all. + + Re-adding an `api_key` field is the first half of re-enabling the wildcard + admin path this service deliberately does without; src/api/auth.py is the + second half, pinned by tests/test_auth.py. + """ + assert "api_key" not in Settings.model_fields + + +def test_local_tolerates_the_dev_default(): + verify_production_secrets( + _settings(gateway_env="local", directory_api_key=SecretStr(DEFAULT_DEV_API_KEY)) + ) + + +@pytest.mark.parametrize("env", ["staging", "production"]) +def test_non_local_refuses_the_dev_default(env): + """The guard fires — and keeps firing once directory_api_key is a SecretStr. + + This is the test platform_auth's secret_guard docstring asks every service + to have. A SecretStr never compares equal to a str, so writing the check as + `settings.directory_api_key == DEFAULT_DEV_API_KEY` (without + .get_secret_value()) makes it False forever: the service boots happily in + production with a publicly-known key and nothing else notices. + """ + with pytest.raises(RuntimeError, match="DIRECTORY_API_KEY"): + verify_production_secrets( + _settings(gateway_env=env, directory_api_key=SecretStr(DEFAULT_DEV_API_KEY)) + ) + + +def test_non_local_accepts_a_real_secret(): + verify_production_secrets(_settings()) + + +def test_directory_key_is_redacted_in_repr(): + s = _settings(directory_api_key=SecretStr("super-secret-value")) + assert "super-secret-value" not in repr(s) + assert "super-secret-value" not in str(s.directory_api_key) + assert s.directory_api_key.get_secret_value() == "super-secret-value" diff --git a/services/gateway/tests/test_directory.py b/services/gateway/tests/test_directory.py new file mode 100644 index 0000000..47b1c77 --- /dev/null +++ b/services/gateway/tests/test_directory.py @@ -0,0 +1,53 @@ +import httpx + +from contracts.directory import DirectoryUnavailable +from src.directory.http_client import HttpDirectoryClient + + +def _client(handler): + return HttpDirectoryClient( + "http://d", "k", client=httpx.Client(transport=httpx.MockTransport(handler)) + ) + + +def test_get_person_by_github_found_and_404(): + def h(req): + if req.url.path == "/people/by-identifier/github/octocat": + return httpx.Response(200, json={"id": "p1"}) + return httpx.Response(404) + + c = _client(h) + assert c.get_person_by_github("octocat") == {"id": "p1"} + assert c.get_person_by_github("ghost") is None + + +def test_get_person_by_github_percent_encodes_login(): + captured = {} + + def h(req): + # raw_path is the on-the-wire (percent-encoded) path; req.url.path is decoded. + captured["path"] = req.url.raw_path.decode() + return httpx.Response(404) + + c = _client(h) + c.get_person_by_github("a b/c#d") + + path = captured["path"] + segment = path.removeprefix("/people/by-identifier/github/") + assert " " not in segment + assert "#" not in segment + assert "/" not in segment + assert segment == "a%20b%2Fc%23d" + + +def test_list_identifiers_and_5xx_raises(): + c = _client( + lambda req: httpx.Response(200, json=[{"provider": "discord", "external_id": "42"}]) + ) + assert c.list_identifiers("p1") == [{"provider": "discord", "external_id": "42"}] + c2 = _client(lambda req: httpx.Response(503)) + try: + c2.get_person_by_github("x") + assert False + except DirectoryUnavailable: + pass diff --git a/services/gateway/tests/test_health.py b/services/gateway/tests/test_health.py new file mode 100644 index 0000000..b9c6887 --- /dev/null +++ b/services/gateway/tests/test_health.py @@ -0,0 +1,10 @@ +from fastapi.testclient import TestClient + +from src.api.app import create_app + + +def test_health_ok(): + client = TestClient(create_app()) + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} diff --git a/services/gateway/tests/test_ratelimit.py b/services/gateway/tests/test_ratelimit.py new file mode 100644 index 0000000..d328c0d --- /dev/null +++ b/services/gateway/tests/test_ratelimit.py @@ -0,0 +1,226 @@ +import json + +from fastapi import Depends, FastAPI +from fastapi.testclient import TestClient +from starlette.requests import Request + +from src.api.auth import require_scope +from src.api.deps import get_storage +from src.api.hashing import generate_key +from src.api.middleware import AuditLogMiddleware +from src.api.ratelimit import ( + ClientRateLimitMiddleware, + FixedWindowCounter, + build_key_rate_limit, + client_identity, +) +from src.storage.in_memory import InMemoryStorageAdapter + + +# --- FixedWindowCounter ------------------------------------------------------- + + +def test_counts_within_a_window_then_refuses(): + c = FixedWindowCounter(limit=2, window_s=60) + assert c.hit("a", now=100.0) is True + assert c.hit("a", now=100.5) is True + assert c.hit("a", now=100.9) is False + + +def test_identities_are_independent(): + c = FixedWindowCounter(limit=1, window_s=60) + assert c.hit("a", now=100.0) is True + assert c.hit("a", now=100.1) is False + assert c.hit("b", now=100.1) is True + + +def test_window_rolls_over(): + c = FixedWindowCounter(limit=1, window_s=10) + assert c.hit("a", now=100.0) is True + assert c.hit("a", now=105.0) is False + assert c.hit("a", now=110.0) is True # new window + + +def test_capacity_is_hard(): + """The whole point: an attacker varying the identity cannot grow this. + + The previous implementation kept an unbounded dict keyed on the raw + X-API-Key header, in front of auth, so anyone could add entries without + limit inside a window — and past 1024 entries each request paid a full scan. + """ + c = FixedWindowCounter(limit=10, window_s=60, capacity=32) + for i in range(10_000): + c.hit(f"attacker-{i}", now=100.0) + assert len(c) == 32 + + +def test_eviction_drops_the_oldest_window_first(): + c = FixedWindowCounter(limit=10, window_s=60, capacity=3) + c.hit("oldest", now=100.0) + c.hit("middle", now=101.0) + c.hit("newest", now=102.0) + c.hit("arrival", now=103.0) + assert "oldest" not in c._hits + assert set(c._hits) == {"middle", "newest", "arrival"} + + +def test_restarting_a_window_moves_an_entry_to_the_back(): + # Ordering is what makes O(1) eviction correct: an entry whose window + # restarts is no longer the oldest and must not be the next one evicted. + c = FixedWindowCounter(limit=10, window_s=10, capacity=2) + c.hit("a", now=100.0) + c.hit("b", now=101.0) + c.hit("a", now=120.0) # a's window restarts; a is now the newest + c.hit("c", now=121.0) # evicts one entry + assert "b" not in c._hits + assert set(c._hits) == {"a", "c"} + + +# --- client_identity ---------------------------------------------------------- + + +def _request(headers: dict, peer: str = "10.0.0.1") -> Request: + raw = [(k.lower().encode(), v.encode()) for k, v in headers.items()] + return Request({"type": "http", "headers": raw, "client": (peer, 1234)}) + + +def test_untrusted_proxy_headers_are_ignored(): + r = _request({"X-Forwarded-For": "1.2.3.4", "X-Real-IP": "5.6.7.8"}) + assert client_identity(r, trust_proxy=False) == "10.0.0.1" + + +def test_trusted_proxy_takes_the_rightmost_hop(): + """The rightmost entry is the only one the client could not have written. + + A client that sends `X-Forwarded-For: 1.2.3.4` gets the proxy's observed + address appended after it. Reading the leftmost value — the usual "original + client" convention — would let anyone rotate their own bucket per request + and walk straight through this layer. + """ + r = _request({"X-Forwarded-For": "1.2.3.4, 9.9.9.9"}) + assert client_identity(r, trust_proxy=True) == "9.9.9.9" + + +def test_trusted_proxy_falls_back_to_real_ip_then_peer(): + assert client_identity(_request({"X-Real-IP": "5.6.7.8"}), trust_proxy=True) == "5.6.7.8" + assert client_identity(_request({}), trust_proxy=True) == "10.0.0.1" + + +# --- per-IP middleware -------------------------------------------------------- + + +def _ip_app(**kwargs) -> TestClient: + app = FastAPI() + + @app.get("/x") + def x(): + return {"ok": True} + + @app.get("/health") + def health(): + return {"status": "ok"} + + app.add_middleware(ClientRateLimitMiddleware, **kwargs) + return TestClient(app) + + +def test_ip_limit_applies_without_any_key(): + # The point of this layer: it bounds unauthenticated callers, who by + # definition have no key to meter. + c = _ip_app(limit=2, window_s=60) + assert c.get("/x").status_code == 200 + assert c.get("/x").status_code == 200 + assert c.get("/x").status_code == 429 + + +def test_rotating_the_api_key_does_not_escape_the_ip_limit(): + c = _ip_app(limit=2, window_s=60) + assert c.get("/x", headers={"X-API-Key": "gw_one"}).status_code == 200 + assert c.get("/x", headers={"X-API-Key": "gw_two"}).status_code == 200 + assert c.get("/x", headers={"X-API-Key": "gw_three"}).status_code == 429 + + +def test_health_is_exempt(): + # Railway's liveness probe hits /health on a fixed interval; 429ing it would + # restart a service that is answering perfectly well. + c = _ip_app(limit=1, window_s=60) + for _ in range(5): + assert c.get("/health").status_code == 200 + + +# --- per-key quota ------------------------------------------------------------ + + +def _keyed_app(limit: int): + store = InMemoryStorageAdapter() + plaintext, prefix, key_hash = generate_key() + store.create_api_key( + name="consumer", prefix=prefix, key_hash=key_hash, scopes=["resolve:discord"], actor="t" + ) + other_plain, other_prefix, other_hash = generate_key() + store.create_api_key( + name="other", + prefix=other_prefix, + key_hash=other_hash, + scopes=["resolve:discord"], + actor="t", + ) + + counter = FixedWindowCounter(limit=limit, window_s=60) + app = FastAPI(dependencies=[Depends(build_key_rate_limit(counter))]) + + @app.get("/probe") + def probe(_=Depends(require_scope("resolve:discord"))): + return {"ok": True} + + app.dependency_overrides[get_storage] = lambda: store + return TestClient(app), plaintext, other_plain + + +def test_key_quota_is_per_issued_key(): + c, key, other = _keyed_app(limit=2) + assert c.get("/probe", headers={"X-API-Key": key}).status_code == 200 + assert c.get("/probe", headers={"X-API-Key": key}).status_code == 200 + assert c.get("/probe", headers={"X-API-Key": key}).status_code == 429 + # A different issued key has its own bucket. + assert c.get("/probe", headers={"X-API-Key": other}).status_code == 200 + + +def test_unauthenticated_requests_never_reach_the_key_counter(): + """401 must win over 429, and an anonymous caller must not create a bucket. + + This is the structural fix: the quota runs behind require_api_key, so the + only strings it can ever be keyed on are names of keys we issued. + """ + c, key, _ = _keyed_app(limit=1) + for _ in range(5): + assert c.get("/probe").status_code == 401 + assert c.get("/probe", headers={"X-API-Key": "gw_bogus"}).status_code == 401 + # The real key still has its full quota — the noise above consumed none. + assert c.get("/probe", headers={"X-API-Key": key}).status_code == 200 + + +def test_429_is_still_audited(capsys): + # Audit is added last, so it is outermost and observes the flood guard's + # short-circuit. Mirrors the order in src.api.app.create_app(). + app = FastAPI() + + @app.get("/x") + def x(): + return {"ok": True} + + app.add_middleware(ClientRateLimitMiddleware, limit=1, window_s=60) + app.add_middleware(AuditLogMiddleware, logger_name="gateway.audit") + + c = TestClient(app) + assert c.get("/x").status_code == 200 + assert c.get("/x").status_code == 429 + + entries = [ + json.loads(line) + for line in capsys.readouterr().out.strip().splitlines() + if line.startswith("{") + ] + statuses = [e.get("status") for e in entries] + assert 429 in statuses + assert 200 in statuses diff --git a/services/gateway/tests/test_resolve.py b/services/gateway/tests/test_resolve.py new file mode 100644 index 0000000..fbbf125 --- /dev/null +++ b/services/gateway/tests/test_resolve.py @@ -0,0 +1,113 @@ +from fastapi.testclient import TestClient + +from contracts.directory import DirectoryUnavailable +from src.api.app import create_app +from src.api.deps import get_directory, get_storage +from src.api.hashing import generate_key +from src.storage.in_memory import InMemoryStorageAdapter + + +class FakeDir: + def __init__(self, person=None, idents=None, down=False): + self._p, self._i, self._down = person, idents or [], down + + def get_person_by_github(self, login): + if self._down: + raise DirectoryUnavailable("x") + return self._p + + def list_identifiers(self, pid): + return self._i + + +def _client(fake): + store = InMemoryStorageAdapter() + plaintext, prefix, key_hash = generate_key() + store.create_api_key( + name="c", prefix=prefix, key_hash=key_hash, scopes=["resolve:discord"], actor="t" + ) + app = create_app() + app.dependency_overrides[get_storage] = lambda: store + app.dependency_overrides[get_directory] = lambda: fake + return TestClient(app), {"X-API-Key": plaintext} + + +def test_resolves_discord_id(): + c, h = _client( + FakeDir( + person={"id": "p1"}, + idents=[ + {"provider": "github", "external_id": "octocat"}, + {"provider": "discord", "external_id": "42"}, + ], + ) + ) + r = c.get("/v1/resolve/discord/octocat", headers=h) + assert r.status_code == 200 and r.json() == {"discord_id": "42"} + + +def test_login_not_found_404(): + c, h = _client(FakeDir(person=None)) + assert c.get("/v1/resolve/discord/ghost", headers=h).status_code == 404 + + +def test_no_discord_identifier_404(): + c, h = _client( + FakeDir(person={"id": "p1"}, idents=[{"provider": "github", "external_id": "x"}]) + ) + assert c.get("/v1/resolve/discord/octocat", headers=h).status_code == 404 + + +def test_the_two_misses_are_indistinguishable(): + """ "Not in the directory" and "in it, but no Discord link" must look identical. + + Otherwise the endpoint is a membership oracle: anyone holding a + resolve:discord key could walk a list of GitHub logins and learn which of + them belong to UTMIST members, which is more than this endpoint is meant to + disclose about anyone. + """ + absent, absent_h = _client(FakeDir(person=None)) + present, present_h = _client( + FakeDir(person={"id": "p1"}, idents=[{"provider": "github", "external_id": "x"}]) + ) + a = absent.get("/v1/resolve/discord/octocat", headers=absent_h) + b = present.get("/v1/resolve/discord/octocat", headers=present_h) + assert a.status_code == b.status_code == 404 + assert a.json() == b.json() + + +def test_directory_down_503(): + c, h = _client(FakeDir(down=True)) + assert c.get("/v1/resolve/discord/octocat", headers=h).status_code == 503 + + +def test_person_without_an_id_fails_closed_to_503(): + # An unrecognised upstream shape is an upstream fault, not a missing record. + # Indexing it blindly would raise KeyError and surface as a 500. + c, h = _client(FakeDir(person={"name": "no id here"})) + assert c.get("/v1/resolve/discord/octocat", headers=h).status_code == 503 + + +def test_response_carries_only_the_discord_id(): + c, h = _client( + FakeDir( + person={"id": "p1", "primary_email": "someone@example.com", "full_name": "Someone"}, + idents=[ + {"provider": "discord", "external_id": "42"}, + {"provider": "uoft_email", "external_id": "someone@utoronto.ca"}, + ], + ) + ) + r = c.get("/v1/resolve/discord/octocat", headers=h) + assert r.status_code == 200 + assert r.json() == {"discord_id": "42"} + body = r.text + for leaked in ("someone@example.com", "Someone", "utoronto.ca", "p1"): + assert leaked not in body + + +def test_requires_scope_and_key(): + c, h = _client( + FakeDir(person={"id": "p1"}, idents=[{"provider": "discord", "external_id": "42"}]) + ) + assert c.get("/v1/resolve/discord/octocat").status_code == 401 diff --git a/services/gateway/tests/test_storage.py b/services/gateway/tests/test_storage.py new file mode 100644 index 0000000..ef09d7d --- /dev/null +++ b/services/gateway/tests/test_storage.py @@ -0,0 +1,29 @@ +from src.storage.in_memory import InMemoryStorageAdapter + + +def test_create_get_verify_revoke_roundtrip(): + a = InMemoryStorageAdapter() + key = a.create_api_key( + name="gh-action", + prefix="abcd1234", + key_hash="HASH", + scopes=["resolve:discord"], + actor="cli", + ) + assert key.name == "gh-action" and key.active is True + assert a.get_api_key_hash("abcd1234") == "HASH" + row = a.get_api_key_by_prefix("abcd1234") + assert row.scopes == ["resolve:discord"] + assert [k.name for k in a.list_api_keys()] == ["gh-action"] + a.touch_api_key_last_used(key.id) + revoked = a.revoke_api_key(key.id, actor="cli") + assert revoked.active is False and revoked.revoked_at is not None + # revoked key: hash still returns, but active=false (auth layer rejects) + assert a.get_api_key_by_prefix("abcd1234").active is False + + +def test_unknown_prefix_returns_none(): + a = InMemoryStorageAdapter() + assert a.get_api_key_hash("nope") is None + assert a.get_api_key_by_prefix("nope") is None + assert a.revoke_api_key(__import__("uuid").uuid4(), actor="cli") is None diff --git a/uv.lock b/uv.lock index d1a34b5..506b201 100644 --- a/uv.lock +++ b/uv.lock @@ -11,6 +11,7 @@ resolution-markers = [ members = [ "connectors", "documentation-system", + "gateway", "llm", "meeting", "platform-auth", @@ -656,6 +657,48 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/66/0a/cf50ecffa1e3747ed9380a3adfc829259f1f86b3fdbd9e505af789003141/fpdf2-2.8.7-py3-none-any.whl", hash = "sha256:d391fc508a3ce02fc43a577c830cda4fe6f37646f2d143d489839940932fbc19", size = 327056, upload-time = "2026-02-28T05:39:14.619Z" }, ] +[[package]] +name = "gateway" +version = "0.1.0" +source = { editable = "services/gateway" } +dependencies = [ + { name = "alembic" }, + { name = "argon2-cffi" }, + { name = "fastapi" }, + { name = "httpx" }, + { name = "platform-auth" }, + { name = "psycopg", extra = ["binary"] }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "sqlalchemy" }, + { name = "uvicorn", extra = ["standard"] }, +] + +[package.optional-dependencies] +dev = [ + { name = "httpx" }, + { name = "pytest" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "alembic", specifier = ">=1.13" }, + { name = "argon2-cffi", specifier = ">=23.1" }, + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.27" }, + { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.27" }, + { name = "platform-auth", editable = "packages/auth" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, + { name = "pydantic", specifier = ">=2.9" }, + { name = "pydantic-settings", specifier = ">=2.5" }, + { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.3" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6" }, + { name = "sqlalchemy", specifier = ">=2.0.35" }, + { name = "uvicorn", extras = ["standard"], specifier = ">=0.32" }, +] +provides-extras = ["dev"] + [[package]] name = "google-api-core" version = "2.31.0"