# Mimic API — sprint 1 + 2 surface This document covers the endpoints the frontend is expected to call in sprints 1 and 2 (auth, engagements, users, engagement members, audit log). 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. This applies to every `/api/v1/engagements//...` route, including the `/members` sub-resource introduced in sprint 2. ### Pagination (D-016) The new sprint-2 endpoints (`/users`, `/audit/log`) return: ```json { "items": [...], "total": , "page": 1, "page_size": 50 } ``` Query: `?page=` (≥1, default 1) and `?page_size=` (default 50, max 200). The existing non-paginated endpoints (`/engagements` list, `/engagements//members`) stay as flat arrays — they'll migrate together in a future opt-in. ## 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. ## Engagement members (sprint 2) The MA6 tenant-scope check (`_engagement_or_404`) runs **before** any membership query: a non-member RT operator targeting an engagement gets the same `404 not_found` as if the engagement did not exist. ### `GET /api/v1/engagements//members` Lists members of the engagement (`engagement.read`). Flat array, not paginated. ```json [ { "engagement_id": "•••", "user_id": "•••", "role": "binôme A", "added_at": "2026-05-23T12:00:00+00:00" } ] ``` ### `POST /api/v1/engagements//members` Adds a member (`engagement.member.manage`). ```json { "user_id": "•••", "role": "member" } ``` - `role` is a free-form label ≤40 chars (D-017); not a permission gate. Defaults to `"member"`. - `201` with the new `EngagementMemberRead`. - `404` if the user does not exist or is disabled. - `409 already_member` if the user is already in this engagement. - Audit `engagement_member.add` row written. ### `DELETE /api/v1/engagements//members/` Revokes membership (`engagement.member.manage`). - `204 No Content` on success. - `404` if the membership does not exist. - Audit `engagement_member.remove` row written. ## Users (sprint 2, `rt_lead` only — D-015) All four routes require `USER_MANAGE`. `rt_operator` and `soc_analyst` get `403 forbidden`. ### `GET /api/v1/users` Paginated. Optional filter `?type=rt_lead|rt_operator|soc_analyst`. ```json { "items": [ { "id": "•••", "email": "alice@example.org", "display_name": "Alice", "type": "rt_lead", "disabled_at": null, "last_login_at": "2026-05-23T08:00:00+00:00", "created_at": "2026-05-21T10:00:00+00:00" } ], "total": 1, "page": 1, "page_size": 50 } ``` ### `POST /api/v1/users` Body: ```json { "email": "newuser@example.org", "display_name": "New User", "password": "longenough", "type": "rt_operator" } ``` - `password` ≥ 8 chars, ≤ 128. - `type` ∈ `rt_operator | rt_lead | soc_analyst`. Group membership is wired automatically to the matching F11 group. - `201` with the created `UserRead`. - `409 email_taken` if a user with that email exists (whether active or already disabled). - Audit `user.create` row written. ### `PATCH /api/v1/users/` Partial update — every field is optional. ```json { "display_name": "Renamed", "type": "rt_lead", "password": "newlongenough" } ``` - Changing `type` realigns the user's global F11 group membership; existing per-engagement memberships are preserved. - `password` rotates the bcrypt hash; never logged in audit metadata. - `200` with the updated `UserRead`. - `404` if the user does not exist. - Audit `user.update` row written (lists changed fields; flags `password_rotated`). ### `DELETE /api/v1/users/` Soft-disable: sets `disabled_at = now()`. The user can no longer log in; `load_user` returns `None` so existing sessions become anonymous on next request. - `204 No Content`. Idempotent: a second call on a disabled user also returns `204` (no audit row). - `404` if the user does not exist. - Audit `user.disable` row written. ## Audit log (sprint 2, `rt_lead` only — F11 `audit.read`) ### `GET /api/v1/audit/log` Paginated, descending by `ts`. Filters: | Query | Type | Meaning | |-------|------|---------| | `action` | string | exact match (`user.create`, `engagement.update`, …) | | `actor_id` | UUID | filter by acting user | | `resource_type` | string | exact match (`engagement`, `user`, …) | | `since` | ISO 8601 | rows with `ts >= since` | | `until` | ISO 8601 | rows with `ts <= until` | Response: ```json { "items": [ { "id": "•••", "ts": "2026-05-23T12:00:00+00:00", "actor_id": "•••", "action": "engagement.create", "resource_type": "engagement", "resource_id": "•••", "metadata_json": { "client_name": "Acme" }, "prev_hash": "•••", "row_hash": "•••", "source_ip": "127.0.0.1", "user_agent": "curl/8.5.0", "comment": null } ], "total": 42, "page": 1, "page_size": 50 } ``` `prev_hash` / `row_hash` are exposed as-is to support future client-side chain verification (D-013). ## 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.