# 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`. - **Trailing slash**: routes accept the URL with **or without** a trailing slash. The app is configured with `strict_slashes=False`, so Werkzeug never issues a 308 redirect (which can drop the session cookie on some browsers). Use whichever form your client prefers; `docs/api.md` writes the no-slash form. - **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**: **every** failure returns the same shape, served by a global `HTTPException` handler: ```json { "error": "", "message": "", "details": ... } ``` `details` is only present for `422` (Pydantic per-field error list). Codes are stable identifiers; messages are human-readable but not localized. | Status | `error` code | Use | |--------|--------------|-----| | 200 | — | OK | | 201 | — | Resource created | | 204 | — | OK, no body | | 400 | `bad_request` | Malformed request (e.g. missing JSON body) | | 401 | `not_authenticated` or `invalid_credentials` | Anonymous / bad creds | | 403 | `forbidden` | Authenticated but missing permission | | 404 | `not_found` | Resource not found (also tenant-scope denials, see below) | | 405 | `method_not_allowed` | Method not allowed for that route | | 415 | `unsupported_media_type` | Wrong `Content-Type` on a body | | 422 | `validation_error` | Pydantic — see `details` | | 429 | `rate_limited` | Reserved for future limiter | | 500 | `internal_error` | Opaque — no leak | ### 422 `details` shape `details` is the raw Pydantic `errors()` list — one entry per failed field: ```json { "error": "validation_error", "message": "request failed", "details": [ { "type": "missing", "loc": ["client_name"], "msg": "Field required", "input": { "description": "no client_name" } } ] } ``` Use `details[i].loc` to map the error back to a form field. ### 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.