docs: sprint 2 surface in docs/api.md + D-015/D-016/D-017 + changelog

- `docs/api.md` extended with the sprint-2 surface: pagination envelope
  conventions, engagement members (GET/POST/DELETE), users (GET paginated
  with `?type=`, POST, PATCH, DELETE-soft), audit log viewer with its
  five filters. Anti-enumeration semantics (404 on foreign members) made
  explicit. Drive-by fix: `/engagements<eid>` → `/engagements/<eid>`.
- `tasks/spec-decisions.md` logs the three sprint-2 decisions verbatim:
  - **D-015** USER_MANAGE permission (wording from spec-analyst).
  - **D-016** pagination envelope shape (`{items, total, page, page_size}`).
  - **D-017** `engagement_member.role` stays a free-form label.
- `CHANGELOG.md` summarises the sprint with hashes / behaviours / decisions.
This commit is contained in:
knacky
2026-05-23 15:53:45 +02:00
parent 4bade795fd
commit 76f8443ac2
3 changed files with 269 additions and 3 deletions

View File

@@ -5,6 +5,46 @@ Versioning starts at `0.1.0` when sprint 0 lands.
## [Unreleased] ## [Unreleased]
### Sprint 2 — user mgmt + engagement members + audit viewer (`feature/backend-user-mgmt`)
- **`USER_MANAGE` permission** (D-015) added to the F11 matrix; `rt_lead` only.
Migration `202605230001_add_user_manage_permission` adds `user.manage` to
the `permission` table and ties it to the `rt_lead` group. The
`test_migration_seed_matches_current_matrix` invariant is generalised to
the union "initial frozen delta migrations" so future sprints can keep
adding permissions via new migrations without editing the historical one.
- **User CRUD** (`/api/v1/users`):
- `GET` paginated list (filter `?type=`).
- `POST` creates a user, hashes the password, wires the F11 group membership
automatically, returns `409 email_taken` on duplicate.
- `PATCH` partial update; changing `type` realigns the global group
membership and leaves per-engagement memberships untouched.
- `DELETE` soft-disables via `disabled_at`; idempotent (returns 204 even
when already disabled).
- Every mutation writes an audit row (`user.create` / `update` / `disable`).
- **Engagement members** (`/api/v1/engagements/<eid>/members`):
- `GET`, `POST`, `DELETE`. `_engagement_or_404` runs *before* any membership
query so an RT operator targeting a foreign engagement receives the same
404 as for a non-existent id (anti-enumeration).
- `role` is a free-form ≤40-char label (D-017). Default `"member"`.
- `409 already_member` on duplicate.
- **Audit log viewer** (`/api/v1/audit/log`): paginated, `rt_lead` only via
`AUDIT_READ`. Filters: `action`, `actor_id`, `resource_type`, `since`,
`until` (ISO 8601). Exposes `prev_hash` / `row_hash` so future clients can
verify the chain.
- **Pagination envelope** (D-016): `Page[T]` schema
`{items, total, page, page_size}` and `PageQuery` for parsing
`?page=&page_size=` (max 200). Used by `/users` and `/audit/log` this
sprint; existing flat-array endpoints stay unchanged.
- **Spec decisions** D-015, D-016, D-017 logged.
- **Tests**: 11 new unit tests (Pydantic shapes + pagination bounds) + 5 new
integration tests covering the critical MA6 scenario (`rt_lead creates
rt_operator → assigns engagement A → operator only sees A`), the RBAC
gate on `USER_MANAGE`, the 409 on duplicate emails, the audit pagination,
and the soft-disable login-block path.
- **`docs/api.md`** extended with the sprint-2 surface; the typo
`/engagements<eid>``/engagements/<eid>` fixed in passing.
### Sprint 1 — backend follow-up fixes (`feature/backend-auth-wiring`) ### Sprint 1 — backend follow-up fixes (`feature/backend-auth-wiring`)
- **Global JSON error envelope** — register `@app.errorhandler(HTTPException)` - **Global JSON error envelope** — register `@app.errorhandler(HTTPException)`

View File

@@ -1,6 +1,7 @@
# Mimic API — sprint 1 surface # Mimic API — sprint 1 + 2 surface
This document covers the endpoints the frontend is expected to call in sprint 1. 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 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` 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 and `MIMIC_CORS_ORIGINS` is set (the prod reverse proxy serves the SPA on the
@@ -68,6 +69,19 @@ 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 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. neighbouring engagement is not leaked between teams. RT leads see everything.
This applies to every `/api/v1/engagements/<eid>/...` 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": <n>, "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/<id>/members`)
stay as flat arrays — they'll migrate together in a future opt-in.
## Authentication ## Authentication
### `POST /api/v1/auth/login` ### `POST /api/v1/auth/login`
@@ -148,7 +162,7 @@ Response — `200`:
] ]
``` ```
### `GET /api/v1/engagements<eid>` ### `GET /api/v1/engagements/<eid>`
Same payload shape as the list element. Returns 404 if the engagement does not Same payload shape as the list element. Returns 404 if the engagement does not
exist or the caller is not a member (MA6). exist or the caller is not a member (MA6).
@@ -178,6 +192,167 @@ 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 This will change in a future sprint when membership becomes the single scope
authority. 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/<eid>/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/<eid>/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/<eid>/members/<uid>`
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/<uid>`
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/<uid>`
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 ## Worked example
1. Create a local admin from the CLI: 1. Create a local admin from the CLI:

View File

@@ -152,3 +152,54 @@ extension owns the registry).
on `UuidPkMixin`. Foreign-key UUID columns rely on SQLAlchemy 2's built-in on `UuidPkMixin`. Foreign-key UUID columns rely on SQLAlchemy 2's built-in
`Uuid` mapping via `Mapped[uuid.UUID]`. No `type_annotation_map` on the `Uuid` mapping via `Mapped[uuid.UUID]`. No `type_annotation_map` on the
declarative base. declarative base.
### D-015 — User management permission
**Decision**: Add `USER_MANAGE = "user.manage"` to the `Permission` enum in
`backend/src/mimic/rbac/matrix.py`. This permission gates all `/api/v1/users`
CRUD endpoints (list, create, update/disable). It is granted exclusively to
`rt_lead` (already holds ALL_PERMISSIONS — no change to GROUP_PERMISSIONS dict).
**Why**: The F11 matrix does not explicitly list "manage users" as a named
permission, but spec §9 routes assign `/admin` (users, audit log) to Lead RT only.
The CLI `mimic-cli user create` covered creation out-of-band but sprint 2 adds a
UI-facing REST endpoint, which requires a named permission for `@require_perm`
decorator + testability.
**How to apply**: Backend uses `@require_perm(Permission.USER_MANAGE)` on all
`/api/v1/users` endpoints. No change to GROUP_PERMISSIONS needed — rt_lead holds
ALL_PERMISSIONS already. rt_operator and soc_analyst get 403 automatically.
### D-016 — Pagination envelope shape
**Context.** Sprint 2 adds two paginated endpoints (`/users` and `/audit/log`);
sprint 3+ will paginate TTPs and scenarios. A consistent shape avoids two
client-side parsers.
**Decision.** Standard envelope:
```json
{ "items": [...], "total": <n>, "page": 1, "page_size": 50 }
```
- Query params: `?page=` (≥1, default 1), `?page_size=` (default 50, max 200).
- `total` is computed via a `SELECT COUNT(*)` against the same filtered query.
- Existing non-paginated endpoints (`GET /api/v1/engagements`) are **not**
migrated this sprint — changing them retroactively would break the frontend
client that already shipped. They'll migrate together later via either a
`/api/v2/` bump or an opt-in `?paginate=true` flag.
**How to apply.** `mimic.schemas.pagination.Page[T]` + `PageQuery` provide the
shape and the validated query parsing; `mimic.api._helpers.parse_page_query()`
is the canonical entrypoint inside blueprints.
### D-017 — `engagement_member.role` as a free-form label
**Context.** The `engagement_member.role` column is `String(40)` (sprint 0).
Sprint 2 needs to know what to validate at the API boundary.
**Decision.** Treat `role` as a free-form informational label, not as an
authorization gate. Application-level RBAC stays the responsibility of the F11
`group` membership; `role` documents who-does-what on the engagement
(e.g. `"member"`, `"lead-on-mission"`, `"binôme A"`, `"shadow"`). Default to
`"member"` when not provided. Validation: 140 chars.
**How to apply.** `EngagementMemberCreate` uses a `str` field with the
140-char bound; no enum to maintain. If future code needs a typed role,
introduce a separate column (do not repurpose this one).