From dd321c2cd0217bc154be51980950f9b2c8854c4b Mon Sep 17 00:00:00 2001 From: knacky Date: Sat, 23 May 2026 04:22:03 +0200 Subject: [PATCH] docs: add api.md contract for sprint 1 + update changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/api.md: contract the frontend consumes — base URL, auth transport (Flask session cookie, credentials: include), uniform error envelope, MA6 tenant-scope behaviour (404 not 403), per-endpoint shape for /auth/{login,logout,me} and /engagements GET/POST/GET-by-id, plus a worked example walking through CLI bootstrap → login → POST engagement → list → logout. - CHANGELOG.md: sprint-1 entry summarising the three endpoints, the dev- only CORS, the AuthUser extension, the audit rows, and the test coverage. --- CHANGELOG.md | 29 ++++++++++ docs/api.md | 158 +++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 docs/api.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3dcef..9cd8779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ Versioning starts at `0.1.0` when sprint 0 lands. ## [Unreleased] +### Sprint 1 — backend auth wiring (`feature/backend-auth-wiring`) + +- **`POST /api/v1/auth/login`** — local-credentials login. Body `{username, + password}`; success returns the `CurrentUser` shape (`user_id`, `username`, + `display_name`, `role`, `permissions`, `groups`) and sets a Flask session + cookie. Failures return a uniform `401 invalid_credentials` envelope; a + bcrypt round runs against a dummy hash on unknown users to flatten the + timing signal. +- **`POST /api/v1/auth/logout`** — clears the session, returns `204`. Writes + an `auth.logout` audit row. +- **`GET /api/v1/auth/me`** — rehydrates the frontend at boot; returns the + current principal or `401 not_authenticated`. +- **Error envelope** — every API failure now returns + `{error: "", message: ""}`. `LoginManager.unauthorized_handler` + is wired to the same shape so `@login_required` 401s match. +- **Dev-only CORS** — `flask-cors` wraps `/api/*` for the origins in + `MIMIC_CORS_ORIGINS` only when `MIMIC_ENV=development`. Prod keeps + same-origin via the reverse proxy. +- **`AuthUser` extended** — carries `display_name` + `user_type` so the + serialiser can return them. +- **Audit** — `auth.login` and `auth.logout` rows go through the existing + hash-chained writer. +- **Docs** — `docs/api.md` describes the contract the frontend consumes + (login flow, CurrentUser shape, error envelope, MA6 tenant-scope behaviour). +- **Tests** — 5 unit tests on the schemas + serializer; integration scaffold + test `tests/integration/test_auth_engagement_e2e.py` exercises the full + login → /me → POST engagement → list → logout loop on a testcontainers + Postgres. + ### Team decisions (2026-05-21) - **Q1** — SOC client collaboration in the live cockpit is assumed valid (no PoC sheet). diff --git a/docs/api.md b/docs/api.md new file mode 100644 index 0000000..c11569a --- /dev/null +++ b/docs/api.md @@ -0,0 +1,158 @@ +# Mimic API — sprint 1 surface + +This document covers the endpoints the frontend is expected to call in sprint 1. +Everything is JSON, every protected route relies on the Flask session cookie set +by `POST /api/v1/auth/login`. CORS is enabled only when `MIMIC_ENV=development` +and `MIMIC_CORS_ORIGINS` is set (the prod reverse proxy serves the SPA on the +same origin). + +## Conventions + +- **Base URL**: `/api/v1/`. +- **Auth transport**: Flask session cookie (`HttpOnly`, `SameSite=Lax`, + `Secure` in production). The browser must send `credentials: "include"` on + every request. +- **Content negotiation**: requests and responses use `application/json`. +- **Error envelope**: failures return `{"error": "", "message": ""}` + with the appropriate HTTP status. Codes are stable identifiers (snake_case), + messages are human-readable but not localized. + +| Status | Use | +|--------|-----| +| 200 | OK | +| 201 | Resource created | +| 204 | OK, no body | +| 400 | Malformed request (e.g. missing JSON body) | +| 401 | `not_authenticated` or `invalid_credentials` | +| 403 | `forbidden` — authenticated but missing permission | +| 404 | Resource not found (also returned for tenant-scope denials, see below) | +| 422 | Pydantic validation error | +| 500 | Internal — opaque, no leak | + +### Tenant scope leak prevention (MA6 — F11) + +RT operators only see engagements they are members of. Requests targeting an +engagement they don't belong to return **404**, never 403, so the existence of a +neighbouring engagement is not leaked between teams. RT leads see everything. + +## Authentication + +### `POST /api/v1/auth/login` + +Body: +```json +{ "username": "alice@example.org", "password": "•••••" } +``` + +`username` maps to the `user.email` column server-side (kept "username" in the +HTTP contract so future identity sources can route through the same endpoint). + +Success — `200`: +```json +{ + "user_id": "0c9e3a3a-7c8b-4d5e-9f10-1a2b3c4d5e6f", + "username": "alice@example.org", + "display_name": "Alice", + "role": "rt_lead", + "permissions": ["engagement.create", "engagement.read", "..."], + "groups": ["rt_lead"] +} +``` + +Failures (all 401, uniform message — no enumeration leak between "unknown +user" and "wrong password"): +```json +{ "error": "invalid_credentials", "message": "invalid username or password" } +``` + +The endpoint runs a bcrypt round against a dummy hash when the user does not +exist, so request timing does not leak the username's existence either. + +Side effects on success: +- A Flask session is established (cookie set, marked `permanent`). +- `user.last_login_at` is updated. +- An `auth.login` audit row is written. + +### `POST /api/v1/auth/logout` + +Requires an active session. + +- `204 No Content` on success — cookie is cleared and an `auth.logout` audit + entry is written. +- `401 not_authenticated` if there is no active session. + +### `GET /api/v1/auth/me` + +Returns the current principal in the same shape as `POST /login`. The frontend +calls this at boot to rehydrate the application state. + +- `200` with the `CurrentUser` payload when authenticated. +- `401 not_authenticated` when there is no session cookie or the user has been + disabled since login. + +## Engagements + +### `GET /api/v1/engagements/` + +Lists engagements visible to the caller (`engagement.read` permission). + +- RT lead: all engagements. +- RT operator: only those for which a row in `engagement_member` ties the + authenticated user to the engagement. + +Response — `200`: +```json +[ + { + "id": "•••", + "client_name": "Demo Client", + "description": null, + "status": "draft", + "c2_type": "mythic", + "start_date": null, + "end_date": null + } +] +``` + +### `GET /api/v1/engagements/` + +Same payload shape as the list element. Returns 404 if the engagement does not +exist or the caller is not a member (MA6). + +### `POST /api/v1/engagements/` + +Creates an engagement (`engagement.create` permission). + +Body: +```json +{ + "client_name": "Demo Client", + "description": "Internal Q3 drill", + "c2_type": "mythic", + "start_date": null, + "end_date": null +} +``` + +- `201` with the created engagement. +- `422` on Pydantic validation failure (returns the per-field error list). +- `created_by_id` is set from the current session. +- An `engagement.create` audit row is written. + +The RT lead currently does **not** get a per-engagement `engagement_member` row +on creation; they see every engagement via the `is_rt_lead()` short-circuit. +This will change in a future sprint when membership becomes the single scope +authority. + +## Worked example + +1. Create a local admin from the CLI: + ```bash + .venv/bin/mimic-cli user create --email alice@example.org --type rt_lead + ``` +2. `POST /api/v1/auth/login` with the credentials — receive the user payload + plus the session cookie. +3. `POST /api/v1/engagements/` with a body — receive the engagement. +4. `GET /api/v1/engagements/` — see the new engagement in the list. +5. `POST /api/v1/auth/logout` — session cleared.