21 Commits

Author SHA1 Message Date
knacky
76f8443ac2 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.
2026-05-23 15:53:45 +02:00
knacky
4bade795fd test(backend): sprint 2 unit + integration coverage
Unit (`tests/unit/test_user_schemas.py`):
- 4 tests on `UserCreate` (happy path, password min length, email
  validation, invalid type).
- 2 tests on `UserUpdate` (all-optional, password validation when set).
- 3 tests on `EngagementMemberCreate` (default `"member"`, explicit role,
  max-length 40).
- 4 tests on `PageQuery` (defaults, offset arithmetic, page_size cap,
  page lower bound).

Integration (`tests/integration/test_user_mgmt_e2e.py`, marked
`integration`):
- The critical MA6-in-practice flow: rt_lead creates rt_operator, assigns
  to engagement A, the operator signs in, lists engagements and sees only
  A, `GET /engagements/B` returns 404 (anti-leak), `GET /engagements/B/members`
  returns 404 too, `/engagements/A/members` is reachable, `GET /users` is
  forbidden for the operator.
- `USER_MANAGE` gate: anonymous → 401, operator session → 403,
  lead session → 200.
- 409 `email_taken` on duplicate `POST /users`.
- `/audit/log` is lead-only, paginates with `page_size`, filters by
  `?action=`.
- Disabling a user blocks subsequent logins (same uniform
  `invalid_credentials` envelope as for bad passwords — no enumeration
  leak of "this account was disabled").

74 unit tests pass (61 sprint 1 + 13 sprint 2); integration tests run on
the testcontainers Postgres fixture in CI.
2026-05-23 15:53:35 +02:00
knacky
9f75f119f0 feat(backend): engagement members + audit log viewer (sprint 2)
Engagement members on `/api/v1/engagements/<eid>/members`:
- `GET` lists members (flat array, ordered by `added_at`). Permission
  `ENGAGEMENT_READ`.
- `POST` adds a member. Permission `ENGAGEMENT_MEMBER_MANAGE`. Body
  `{user_id, role?}`; `role` defaults to `"member"` (D-017). Returns 201
  with `EngagementMemberRead`, 404 if the user is disabled/unknown, or
  `409 already_member` on duplicate.
- `DELETE /members/<uid>` revokes. 204 on success, 404 if the membership
  doesn't exist.

Every route reuses `_engagement_or_404` *before* any membership query, so
an RT operator targeting a foreign engagement receives the same 404 as for
a non-existent ID — matching the MA6 anti-leak posture flagged by
spec-analyst on this sprint.

Audit log viewer on `/api/v1/audit/log`:
- Single endpoint `GET`, paginated `Page[AuditLogEntry]`, gated by
  `AUDIT_READ` (rt_lead only).
- Filters: `?action=`, `?actor_id=`, `?resource_type=`, `?since=`,
  `?until=`. Times are ISO 8601; invalid input goes through the global 422
  envelope with a `loc` field for the bad parameter.
- Exposes `prev_hash` / `row_hash` to support future client-side
  chain-verification (D-013 stayed v1).
- Sorted by `ts DESC` so the most recent activity is the first page.

Blueprints registered in `api/__init__.py`.
2026-05-23 15:53:22 +02:00
knacky
e2f030e0e1 feat(backend): /api/v1/users CRUD endpoints (sprint 2)
Four routes, all gated by `USER_MANAGE` (D-015 — rt_lead only):

- `GET /api/v1/users` paginated, optional `?type=` filter. Returns
  `Page[UserRead]`.
- `POST /api/v1/users` hashes the password with bcrypt, attaches the user
  to the matching F11 group (rt_operator → `rt_operator`, rt_lead →
  `rt_lead`, soc_analyst → `soc_analyst`). Returns 201 with `UserRead`, or
  409 `email_taken` on duplicate email (active or already-disabled).
- `PATCH /api/v1/users/<uid>` partial. Changing `type` realigns the user's
  *global* F11 membership (engagement_id IS NULL) and leaves per-engagement
  memberships untouched. Password rotation rehashes with bcrypt; audit row
  carries a `password_rotated` flag rather than logging the value.
- `DELETE /api/v1/users/<uid>` sets `disabled_at = now()`. Idempotent —
  a second call on a disabled user returns 204 without an extra audit row.
  Hard-delete is intentionally absent: keeps audit-trail FKs valid and
  matches NF-AUDIT (we never lose actor-id linkage).

`_TYPE_TO_GROUP` translates `UserType` enum → `GroupName`; `_resolve_group`
loads the corresponding row from `group` (raises 500 if the seed didn't
run, surfaced through the global JSON error handler).
2026-05-23 15:53:09 +02:00
knacky
feda5d1485 feat(backend): add pagination + user/member/audit DTOs (D-016)
Adds the `Page[T]` envelope `{items, total, page, page_size}` documented in
D-016, the matching `PageQuery` for `?page=&page_size=` parsing (default 50,
max 200), and `parse_page_query()` helper for blueprints.

DTOs:
- `UserRead` / `UserCreate` / `UserUpdate` (sprint 2). `UserRead` never
  exposes `local_password_hash`. `UserCreate` validates email via
  pydantic-email-validator and pins password to 8..128 chars.
- `EngagementMemberRead` / `EngagementMemberCreate`. `role` is a free-form
  string ≤ 40 chars (D-017), defaulting to `"member"`.
- `AuditLogEntry` for the upcoming audit viewer.
2026-05-23 15:52:56 +02:00
knacky
48a1c756bf feat(backend): add USER_MANAGE permission + delta migration (D-015)
Adds `Permission.USER_MANAGE = "user.manage"` to the F11 matrix. rt_lead
already holds ALL_PERMISSIONS so GROUP_PERMISSIONS is unchanged — rt_lead
gets the new permission automatically, rt_operator and soc_analyst get 403.

Alembic migration `202605230001_add_user_manage_permission`:
- inserts the `user.manage` row into `permission`,
- inserts the `(rt_lead, user.manage)` link into `group_permission`,
- exposes `_DELTA_PERMISSIONS` / `_DELTA_GROUP_PERMISSIONS` for parity tests.

The previous `test_frozen_*_matches_runtime` invariant (MA3) is generalised
to "runtime = initial frozen ∪ deltas of every migration in `_DELTAS`". New
migrations register themselves there without editing the historical one.

Verbatim wording from spec-analyst is recorded as D-015 in
`tasks/spec-decisions.md` (separate commit).
2026-05-23 15:52:47 +02:00
ux-frontend
140a34b81e fix(frontend): align types + UI to backend contract (docs/api.md @ dd5c508)
Some checks failed
ci / backend (lint + typecheck + unit tests) (push) Failing after 0s
ci / frontend (lint + typecheck + build + unit tests) (push) Failing after 0s
Backend pushed the authoritative contract in docs/api.md and tightened
the error envelope via a global HTTPException handler (dd5c508). This
commit folds the frontend onto that contract — every drift flagged by
the code-reviewer MAJOR is closed.

Types (src/types/api.ts)
- User: `id` → `user_id`; `display_name` is `string | null`; add
  `permissions: string[]` and `groups: string[]`; drop `engagement_id`
  and `engagement_name` (not part of CurrentUser).
- Engagement: drop `name`, `client_name` is non-null `string`; status
  enum aligned to `draft | active | closed | archived`; `c2_type` is
  non-null `C2Type`; drop `created_at` (not in EngagementRead v1).
- EngagementCreate body: `client_name` required, plus optional
  `description`, `c2_type`, `start_date`, `end_date`. No `name`.
- Replace ApiError + ApiValidationError with a single uniform envelope:
  `{ error: string, message: string, details?: PydanticErrorItem[] }`,
  matching the new HTTPException handler. PydanticErrorItem is the
  per-field shape on 422 (`{ loc, msg, type }`).

Fetch client (src/lib/api.ts)
- `bodyAsApiError` now recognizes the uniform envelope by shape
  (error+message strings). Anything else returns null so callers fall
  back to a generic message — keeps us robust if the backend ever
  emits a non-JSON response.

Engagements API (src/screens/engagements/engagementsApi.ts)
- Drop the `{ items: [] }` envelope tolerance — backend serves a bare
  `Engagement[]`.
- Hit `/engagements/` with trailing slash explicitly; backend now sets
  `strict_slashes=False` but staying consistent with docs/api.md.

EngagementsPage
- Status tone map switched to the new enum (`draft → pending`,
  `closed → soc`).
- Drop "Name" column. `client_name` is the primary identifier; the
  description column replaces the now-meaningless name field.
- `c2_type` is non-null, so no nullable rendering path.

EngagementCreateDialog
- Drop `name` field. New required field is `client_name`; add a
  `c2_type` select (default `mythic`); brief textarea stays optional.
- `mapValidationErrors` now reads `body.details[*].loc` (last segment
  matches the form field) — direct alignment with the backend's new
  shape after dd5c508.
- 401 still surfaces "Session expirée"; 403 gains a dedicated message;
  other errors fall back to a capitalized backend `message` when
  available, then to a generic French string.

Sidebar
- Display fallback: `user.display_name ?? user.username` (now nullable).
- Drop the `ENG · {engagement_name}` line; show `user.username` (the
  email) as the secondary identity instead.

LoginPage
- Field label "Username" → "Email or username" so RT users with email
  accounts find the field semantically obvious (per docs/api.md note
  on the username/email mapping).

Tests (Vitest, 14 cases, all green)
- Refreshed fixtures to the new shapes (no more `name`, no
  `created_at`, status `draft`, envelopes carry `error`+`message`).
- New 422 test exercises the `details[*].loc` mapping shape.
- New 401 test on the dialog covers the top-of-form alert path.
2026-05-23 11:14:32 +02:00
ux-frontend
ec7effcaac test(frontend): Vitest coverage on sprint 1 wiring
Adds a small test harness (src/test/testUtils.tsx):
- renderWithProviders mounts a fresh QueryClient (no retries, no cache)
  + MemoryRouter so screens using useNavigate / <Link> don't crash.
- installFetchMock(responses[]) replaces globalThis.fetch with a typed
  sequence of canned responses and records call URLs + init.

Specs (10 cases, all green):

LoginPage.test.tsx
- happy path: submit posts to /api/v1/auth/login with credentials:'include',
  correct JSON body shape (username/password).
- 401 surfaces "Identifiants invalides" and does NOT leak the backend
  detail string.
- empty submit is intercepted by HTML5 `required` — no fetch fires.

EngagementsPage.test.tsx
- loading row renders while /engagements is in flight.
- empty state renders on 200 [].
- error state + Retry button render on 500.
- populated table renders the snake_case fields correctly (name,
  client_name, c2_type uppercased).

EngagementCreateDialog.test.tsx
- client-side validation: empty name blocks submission, no fetch fires.
- 422 Pydantic error on the `name` field maps to the inline message
  next to the input.
- 201 success triggers onClose() and POSTs to /api/v1/engagements.
2026-05-23 11:12:06 +02:00
ux-frontend
20fbcdf1f8 feat(frontend): wire LoginPage + EngagementsPage + create dialog to backend
LoginPage
- RT mode now POSTs /api/v1/auth/login with controlled username/password
  fields. Success seeds the session cache via queryClient.setQueryData and
  navigates to /engagements. 401 surfaces as the generic
  "Identifiants invalides" — no echo of the backend detail (avoids
  user enumeration leaks).
- SOC mode kept visually for masthead continuity but disabled with a
  "sprint 2" placeholder pointing at the deferred
  POST /api/v1/auth/soc/session endpoint.
- Removed the sprint-0 mock role-picker.

EngagementsPage
- MOCK_ENGAGEMENTS dropped. useQuery against fetchEngagements (handles
  both bare-array and { items: [] } envelope shapes — backend has not
  pinned this yet).
- Distinct loading / empty / error states. Error row surfaces an HTTP
  code and a Retry button. Empty state offers the create dialog.
- Column shape aligned with the real Engagement schema (snake_case:
  name, client_name, c2_type, start_date, end_date). Dropped mock-only
  columns (operators, socAnalysts) — those land when backend exposes
  /engagements/:id/members and /engagements/:id/soc-sessions counts.

engagementsApi.ts
- fetchEngagements + createEngagement, both bound to /api/v1/engagements.
- ENGAGEMENTS_QUERY_KEY exported so the dialog can invalidate without
  re-knowing the key.

EngagementCreateDialog (frontend-design skill — new non-trivial component)
- "Arm engagement" mission-control dialog. Backdrop is a graphite dim
  with a faint scanline overlay (no soft blur) — reads as "cockpit
  paused while you issue a command", not as a SaaS modal.
- Surface --surface-3 with corner-marks and an amber hairline accent
  under the title; underline-style inputs that light amber on focus;
  label-system uppercase microtypography throughout.
- Esc + outside-click close (suspended while the mutation is in flight).
- Rudimentary tab focus trap.
- 422 Pydantic errors map per-field via the last loc segment;
  401/5xx surface as a generic top-of-form alert.
- On 201 invalidates ['engagements'] and closes.
2026-05-23 11:12:06 +02:00
ux-frontend
f6d4e43e4c feat(frontend): wire session to real /auth/me + drop sessionStorage mock
Foundations for the sprint 1 backend wiring. No UI behavior change beyond
the loading state in AppShell, but everything below the wire is now real:

- vite.config.ts adds `server.proxy['/api']` → http://localhost:5000
  (overridable via VITE_DEV_API_TARGET). In prod Caddy routes /api → backend
  on the same origin, so the same `/api/v1/...` paths work without changes.
- src/types/api.ts hand-rolled against the backend Pydantic schemas.
  User / Engagement / EngagementCreate / Login / ApiError / ApiValidationError.
  Should be regenerated from OpenAPI once backend exposes it.
- src/lib/api.ts: thin fetch wrapper. Always credentials:'include' so the
  HttpOnly session cookie travels. 4xx/5xx normalize into ApiClientError
  with typed `body` (ApiError | ApiValidationError | null). No retry loop —
  that's TanStack Query's policy.
- src/session/sessionApi.ts: 1:1 functions for /auth/me, /auth/login,
  /auth/logout. fetchMe maps 401 → null so "unauthenticated" is data, not
  an error.
- src/session/useSession.ts: now a TanStack Query hook against
  SESSION_QUERY_KEY (`['session']`). Returns { user, isLoading, isError,
  signOut, isSigningOut }. Cookie is the source of truth, server is the
  resolver, query is the cache.
- Drop sessionStorage mock layer entirely: src/mocks/session.ts,
  src/session/SessionContext.{tsx,context.ts}, src/routing/Root.tsx all
  removed. No more provider tree — QueryClientProvider in App.tsx is the
  only global state container.
- AppShell renders a "resolving session" state during /auth/me's first
  flight so users with a valid cookie don't see a /login flash on direct
  navigation to a protected URL.
- StatusRail gains an optional `sessionState="resolving"` slot used by
  the loading shell.
- Sidebar's Sign-out wires POST /auth/logout, invalidates the session
  cache, and always navigates to /login regardless of the call outcome
  (a failed logout still expires the local cache so users aren't stuck
  on a broken cookie).
- types/roles.ts loses SessionUser (replaced by api.ts User which is the
  authoritative shape).
2026-05-23 11:12:06 +02:00
knacky
dd5c508b04 fix(backend): JSON error envelope for every HTTPException + strict_slashes=False
Some checks failed
ci / backend (lint + typecheck + unit tests) (push) Failing after 0s
ci / frontend (lint + typecheck + build + unit tests) (push) Failing after 1s
Two issues spotted by ux-frontend consuming docs/api.md against the actual
code path:

1. `flask.abort(...)` returned the Werkzeug HTML error page for 400/403/404/
   422/etc. — only the 401 paths going through `api_error()` and the
   Flask-Login `unauthorized_handler` honoured the `{error, message}`
   envelope the contract promised. The frontend's `ApiClientError.body`
   parser was forced to fall back to a raw string, and the 422 case
   could not surface Pydantic per-field errors.

   Fix: register `@app.errorhandler(HTTPException)` that serialises every
   `HTTPException` to the same JSON envelope. 422s gain a `details: [...]`
   field holding the Pydantic `errors()` list (`loc` / `msg` / `type`),
   matching the shape now documented in `docs/api.md`.

   A `_HTTP_ERROR_CODES` map maps statuses to stable snake_case codes
   (`bad_request`, `not_found`, `method_not_allowed`,
   `validation_error`, `forbidden`, `internal_error`, ...). Unknown
   statuses fall back to `http_error`.

   `description` is `cast(object, ...)` because the Werkzeug stub pins it
   to `str | None` while `flask.abort(..., description=<list>)` is the
   officially supported way to smuggle a Pydantic errors list to the
   handler.

2. `@bp.get("")` on the engagements blueprint produced `/api/v1/engagements`
   (no slash). Hitting it with a trailing slash issued a 308 redirect,
   and some browsers drop the session cookie across that hop.

   Fix: `app.url_map.strict_slashes = False`. Both forms now match the
   same handler without redirect.

5 new integration tests cover the new envelope shape (422 with details,
unknown 404, malformed-JSON 400) and the dual-slash matching. `docs/api.md`
rewritten to reflect the table of stable codes, the `details` shape, and
the no-trailing-slash convention. `CHANGELOG.md` gains a follow-up entry.

Verification: ruff check / mypy --strict / pytest tests/unit all green
(61 unit + 5 new integration).
2026-05-23 04:33:23 +02:00
knacky
dd321c2cd0 docs: add api.md contract for sprint 1 + update changelog
- 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.
2026-05-23 04:22:03 +02:00
knacky
e1b381af4d test(backend): cover auth schemas + login/engagement E2E
Unit:
- test_auth_schemas: LoginRequest validation (min/max bounds, extra-fields
  policy) + serialize_current_user round-trip (RT lead permission set,
  RT operator subset, display_name None pass-through).

Integration (testcontainers Postgres, marked `integration`):
- test_login_then_create_and_list_engagement: full sprint-1 user journey —
  /me → 401, POST /login → 200, /me → 200, POST /engagements → 201,
  GET /engagements lists the new row, POST /logout → 204, /me → 401.
- test_login_rejects_bad_credentials: wrong password AND unknown user
  return the exact same 401 invalid_credentials envelope (no enumeration
  leak).
- test_logout_without_session_returns_401: /logout on anonymous returns
  the uniform not_authenticated envelope.

Unit total: 61 passed in 0.50s. Integration tests skip locally when
testcontainers is absent.
2026-05-23 04:21:55 +02:00
knacky
38b35c933a feat(backend): wire auth endpoints + dev CORS (sprint 1)
Three login endpoints under /api/v1/auth/ + dev-only CORS so the Vite
frontend can drive the session cookie.

- POST /login validates local credentials and sets a Flask session cookie.
  Returns the CurrentUser shape on 200 (user_id, username=email,
  display_name, role, permissions, groups). Uniform 401 invalid_credentials
  on bad password or unknown user; a bcrypt round against a dummy hash runs
  even on unknown users so the request timing does not enumerate accounts.
  Audits an auth.login row and bumps user.last_login_at.
- POST /logout (login_required) clears the session, returns 204, audits an
  auth.logout row.
- GET /me returns the current principal or 401 not_authenticated. Used by
  the frontend at boot to rehydrate state.

Side wiring:
- LoginManager.unauthorized_handler emits the same {error, message} JSON
  envelope so @login_required 401s match the rest of the API surface.
- api/_helpers gains `serialize_current_user(AuthUser) -> CurrentUser` and
  `api_error(code, message, status)` — used by the auth blueprint and
  available to follow-up endpoints.
- AuthUser carries display_name + user_type now; identity.load_user routes
  through a new `authuser_from_orm()` helper that the login endpoint also
  uses so /login and the user_loader produce identical shapes.
- Dev-only CORS via flask-cors on /api/*, gated on
  MIMIC_ENV=development AND MIMIC_CORS_ORIGINS non-empty. Prod keeps
  same-origin (reverse proxy fronts the SPA + API).
- LoginRequest + CurrentUser DTOs added to mimic.schemas.

No frontend-visible change to engagements (sprint-0 already shipped
created_by_id, audit log, F11 scope).
2026-05-23 04:21:44 +02:00
knacky
a8c5400f97 docs: add production deployment guide
Some checks failed
ci / backend (lint + typecheck + unit tests) (push) Failing after 0s
ci / frontend (lint + typecheck + build + unit tests) (push) Failing after 0s
Operational runbook for rolling Mimic to RT infrastructure. Scope is
the application repo only; the Ansible playbook (D-010) and Caddy
reverse proxy (D-007) are referenced as out-of-scope dependencies.

Sections:

- Host prerequisites (Podman 5, rootless, linger, PostgreSQL 16 reach).
- Filesystem layout: blobs + evidence pools at 0750 under the deploy
  user (D-012), log directory, Quadlet directory.
- Environment variables: split into "required in prod" (MIMIC_SECRET_KEY,
  MIMIC_FERNET_KEY, MIMIC_DATABASE_URL, MIMIC_DATABASE_AUDIT_URL,
  MIMIC_ENV) and "required with safe defaults" (cookie flags, log
  format, CORS origins, blob/evidence roots). Explicit note that the
  two database DSNs must point to two different Postgres roles to
  preserve the audit append-only contract (NF-AUDIT, code-reviewer N5).
- Secrets management: dedicated section addressing PR3 code-reviewer M2.
  File-based generation under ~/secrets with 0700 perms, systemd
  EnvironmentFile or future MIMIC_*_FILE indirection, vault back-up,
  Fernet key rotation requires re-encryption pass.
- Container images: pin policy `:X.Y.Z` (cross-references F-D1), exposed
  ports per layer (backend 5000 as uid 1001, frontend 8080 as uid 101).
- PostgreSQL setup: bootstrap of mimic_audit_writer role with the SQL
  the Ansible playbook runs, plus the fail-loud rationale if the role
  is missing. Alembic upgrade head invocation.
- Quadlet units: backend example with PublishPort 127.0.0.1:5000 (the
  external surface is Caddy, not the backend), EnvironmentFile,
  blob+evidence bind-mounts with `:Z` SELinux relabel.
- Smoke validation: three curl checks (Caddy-fronted /healthz, direct
  backend /healthz, audit DSN presence) with explicit "do not announce
  the release" gate on failure.
- Upgrade procedure: 5-step rolling restart anchored on Quadlet image
  tag edits + alembic upgrade as part of the entrypoint.
- Rollback procedure: image-only (additive schema) vs schema-affecting,
  with alembic downgrade against an explicit revision.
- Open items: explicit pointers to FERNET-KEY, F-D1, F-D2, F-D3
  trackers in tasks/todo.md so future operators see them.

No other file touched; no application code changed.
2026-05-23 03:15:46 +02:00
knacky
c44f8b90ad docs: archive Podman runner setup runbook + track F-D1..F-D5
Some checks failed
ci / backend (lint + typecheck + unit tests) (push) Failing after 1s
ci / frontend (lint + typecheck + build + unit tests) (push) Failing after 0s
Two changes scoped together since both stem from the post-PR2 wrap-up.

docs/podman-runner-setup.md (new, ~190 LOC):

Operational runbook for the gitea-runner host that drives CI. The first
attempt at install hit four traps that this archived version documents
so we don't lose the lesson:

 1. `act_runner register` performs a sanity ping against the container
    daemon before writing the credential. Without the Podman socket
    mounted on the *register one-shot*, register fails silently and no
    .runner file is produced. The runbook mounts the socket on both
    register and daemon containers.
 2. SELinux blocks rootless socket access by default. Quadlet
    SecurityLabelDisable=true (or --security-opt label=disable for the
    legacy CLI form) is the documented bypass. No-op on Debian, required
    on RHEL/Fedora hosts.
 3. The runner user UID is not 1000 on every host (gitea = 1005 here).
    Quadlet `%U` substitution makes the unit portable; hardcoded UIDs
    are explicitly called out as a sprint 0 mistake.
 4. `podman generate systemd` is officially deprecated. Quadlet is the
    only supported pattern going forward and is what this runbook ships;
    legacy alternative is omitted on purpose.

Also captures: token placeholder convention (<TOKEN_FROM_GITEA_UI>,
never the real value in archived docs), single-use semantics, the
"secrets via file, not chat" convention, the `:X.Y.Z` pin policy versus
`:latest` in prod (ties into follow-up F-D1), and a decommissioning
section that cleans up state without nuking the user-level Podman socket.

tasks/todo.md:

New section "Frontend follow-ups (sprint 1+)" with F-D1..F-D5 from
code-reviewer on `chore/frontend-dockerfile` (649194b). All deferred,
none blocking. F-D1 (digest pinning) is project-wide and explicitly
references the backend image and the runner image alongside the
frontend ones for a single chore commit.
2026-05-23 03:08:03 +02:00
knacky
649194b174 chore(frontend): add multi-stage Dockerfile + nginx SPA config
Some checks failed
ci / backend (lint + typecheck + unit tests) (push) Failing after 1s
ci / frontend (lint + typecheck + build + unit tests) (push) Failing after 0s
Production image for the frontend dist.

Stage 1 (build): node:22-alpine, `npm ci --ignore-scripts` from the
committed lockfile, `npm run build`. Output lands in /app/dist.

Stage 2 (runtime): docker.io/nginxinc/nginx-unprivileged:alpine.
- Upstream-maintained variant that runs as the nginx user (uid 101)
  out of the box. /var/cache/nginx and /var/run/nginx are pre-owned,
  no chown gymnastics needed in our layer. Vanilla nginx:alpine fails
  at startup as non-root because client_temp mkdir is denied.
- Listens on 8080 (non-privileged port, matches the unprivileged
  variant convention).
- nginx.conf serves /usr/share/nginx/html with SPA `try_files`
  fallback for client-side routing, long-cache headers on
  /assets/ (Vite hashed bundles), a plaintext /healthz endpoint
  for Caddy / Prometheus blackbox, and server_tokens off.

.dockerignore excludes node_modules, dist, .vite, coverage,
playwright-report, .env*, .git, editor dirs. Keeps .env.example.

Smoke local validated with `podman build -t mimic-frontend:smoke .`
and `podman run -p 127.0.0.1:18080:8080`:
  /healthz -> 200 "ok"
  /        -> 200 index.html (508 B)
  /spa/x   -> 200 (SPA fallback)
  /assets  -> Cache-Control: max-age=31536000, public, immutable
2026-05-22 19:59:09 +02:00
knacky
359225e464 chore(ci): drop transient smoke workflow now that runner is validated
Some checks failed
ci / backend (lint + typecheck + unit tests) (push) Failing after 6s
ci / frontend (lint + typecheck + build + unit tests) (push) Failing after 3s
The smoke workflow was scoped from inception to validate that the
freshly registered gitea-runner picks up jobs with the "linux" label.
It ran green on push of chore/podman-and-ci. Removing per the
"transient, removed after validation" plan recorded in the original
commit (1380672).
2026-05-22 19:49:26 +02:00
knacky
df6294ed7b docs: align doc references with compose.yml rename (code-reviewer M1)
Three docs still referenced the old docker-compose.yml path. Replace
with compose.yml so a future reader cloning at this hash finds the
file at the documented path.

- CHANGELOG.md:31 — backend skeleton recap line.
- docs/architecture.md:28 — deployment artifacts note (D-010 scope).
- tasks/todo.md:9 — B0.1 task description.

Also adds a "CI follow-ups (sprint 1+)" section to tasks/todo.md
capturing the 3 MINOR + 6 NIT deferred from code-reviewer's review
of chore/podman-and-ci, plus a FERNET-KEY tracker for the secret
provisioning before c2_credential.config_fernet (D-004) is wired.
2026-05-22 19:49:16 +02:00
knacky
1380672c03 ci(gitea): add CI workflow + transient smoke validation
All checks were successful
smoke / hello (push) Successful in 0s
Two workflows under .gitea/workflows/:

- ci.yml — runs on push:main and every PR. Two parallel jobs:
  * backend (python:3.12-slim-bookworm): apt deps for psycopg + WeasyPrint,
    pip install -e backend[dev], ruff check + ruff format --check + mypy
    --strict src + pytest tests/unit. Postgres 16 service for any
    integration-style test, env wired via service hostname.
    FERNET_KEY_TEST sourced from Gitea repo secret (no plain value in CI).
  * frontend (node:22-alpine): npm ci, ESLint, TypeScript typecheck,
    Vitest, Vite build.
  Runner label: linux (matches gitea-runner registration).
  Out of scope sprint 0: testcontainers Postgres integration tests
  (Docker-in-Docker rootless setup deferred to nightly job) and
  Playwright E2E (deferred to sprint 1+).

- smoke.yml — transient. Triggers only on push to this branch
  (chore/podman-and-ci) and on workflow_dispatch. Validates that the
  newly registered gitea-runner picks up jobs with the "linux" label.
  Removed in a follow-up commit on this branch once green.
2026-05-22 19:42:23 +02:00
knacky
9ece352659 chore(backend): rename docker-compose.yml -> compose.yml + podman notes
Compose v2 canonical filename (compose.yml) is recognized by both
docker compose and podman compose without preference. The previous
docker-compose.yml worked but signalled a Docker-first stance, while
target deployment is Podman 5.8+ rootless.

- Rename backend/docker-compose.yml -> backend/compose.yml.
- backend/README.md `make db-up` comment uses $(CONTAINER) to mirror
  the Makefile auto-detect (lines 14-16: docker || podman).
- backend/README.md audit-writer bootstrap snippet hints at podman
  fallback explicitly with `command -v` runtime sniff.
- backend/compose.yml comment for audit-writer mentions both runtimes.

No functional change. Makefile $(COMPOSE) target unchanged: Compose v2
discovers compose.yml first in its search order.
2026-05-22 19:41:38 +02:00
57 changed files with 4344 additions and 395 deletions

97
.gitea/workflows/ci.yml Normal file
View File

@@ -0,0 +1,97 @@
name: ci
on:
push:
branches:
- main
pull_request:
jobs:
backend:
name: backend (lint + typecheck + unit tests)
runs-on: linux
container:
image: python:3.12-slim-bookworm
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: mimic_test
POSTGRES_USER: mimic_test
POSTGRES_PASSWORD: mimic_test_password
# Healthcheck so Gitea Actions waits for Postgres readiness.
options: >-
--health-cmd "pg_isready -U mimic_test -d mimic_test"
--health-interval 5s
--health-timeout 3s
--health-retries 10
env:
MIMIC_ENV: test
MIMIC_DATABASE_URL: postgresql+psycopg://mimic_test:mimic_test_password@postgres:5432/mimic_test
MIMIC_DATABASE_AUDIT_URL: postgresql+psycopg://mimic_test:mimic_test_password@postgres:5432/mimic_test
MIMIC_SECRET_KEY: ci-not-secret
MIMIC_FERNET_KEY: ${{ secrets.FERNET_KEY_TEST }}
MIMIC_BLOB_ROOT: /tmp/mimic-blobs
MIMIC_EVIDENCE_ROOT: /tmp/mimic-evidence
steps:
- name: Checkout
uses: actions/checkout@v4
- name: System deps (psycopg + WeasyPrint runtime)
run: |
apt-get update -qq
apt-get install -y --no-install-recommends \
build-essential libpq-dev \
libpango-1.0-0 libpangoft2-1.0-0 libcairo2 libffi-dev
rm -rf /var/lib/apt/lists/*
- name: Install backend
working-directory: backend
run: |
python -m pip install --upgrade pip
pip install -e ".[dev]"
- name: Ruff lint
working-directory: backend
run: ruff check src tests
- name: Ruff format check
working-directory: backend
run: ruff format --check src tests
- name: Mypy strict
working-directory: backend
run: mypy --strict src
- name: Pytest unit
working-directory: backend
run: pytest tests/unit -q
frontend:
name: frontend (lint + typecheck + build + unit tests)
runs-on: linux
container:
image: node:22-alpine
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install
working-directory: frontend
run: npm ci
- name: ESLint
working-directory: frontend
run: npm run lint
- name: TypeScript typecheck
working-directory: frontend
run: npm run typecheck
- name: Vitest
working-directory: frontend
run: npm test
- name: Vite build
working-directory: frontend
run: npm run build

View File

@@ -5,6 +5,95 @@ Versioning starts at `0.1.0` when sprint 0 lands.
## [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`)
- **Global JSON error envelope** — register `@app.errorhandler(HTTPException)`
so every aborted request now flows through the same
`{ "error": "<code>", "message": "<human>", "details"? }` JSON shape that
`docs/api.md` documented but only `api_error()` honoured before. 422
responses surface the Pydantic per-field list under `details` so the
frontend can map errors back to form fields. New stable codes:
`bad_request`, `not_found`, `method_not_allowed`, `validation_error`,
`forbidden`, `internal_error`, etc. (see updated `docs/api.md`).
- **`strict_slashes=False`** on the URL map — `/api/v1/engagements` and
`/api/v1/engagements/` match the same handler. Removes the 308 redirect
that some browsers drop the session cookie through.
- **5 new integration tests** covering both slash variants, 422
`validation_error` envelope shape (incl. `details`), unknown-route 404,
and 400 on a non-JSON body.
- **`docs/api.md`** rewritten: full error code table, 422 `details`
example, trailing-slash policy, dropped trailing slashes from all
endpoint headings.
### 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: "<code>", message: "<human>"}`. `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).
@@ -28,7 +117,7 @@ UX wireframes (mock data). No real connector, no reporting until PR1/PR2/PR3 lan
#### Backend skeleton (`feature/backend-skeleton`)
- `backend/` Python 3.12+ project: `pyproject.toml` (ruff, mypy strict, pytest, coverage 70 %),
`Makefile` (Docker/Podman auto-detect), multi-stage `Dockerfile`, `docker-compose.yml` for
`Makefile` (Docker/Podman auto-detect), multi-stage `Dockerfile`, `compose.yml` for
Postgres dev DB, `.env.example`.
- Full §8 data model in SQLAlchemy 2 typed mapped classes: `engagement`, `c2_credential`,
`host`, `user`, `group`, `permission`, `group_permission`, `user_group`,

View File

@@ -34,7 +34,7 @@ backend/
```bash
make install # uv venv + pip install -e .[dev]
make db-up # docker compose up -d postgres
make db-up # $(CONTAINER) compose up -d postgres (auto-detect docker|podman)
make db-bootstrap # one-time: create the mimic_audit_writer role (see below)
make db-migrate # alembic upgrade head
make run # flask run (debug)
@@ -49,7 +49,9 @@ make lint # ruff + mypy strict
(decision D-010). For local development, create it manually after `make db-up`:
```bash
docker exec -it mimic-postgres psql -U mimic_app -d mimic \
# Substitute "podman" for "docker" if your runtime is Podman.
$(command -v docker || command -v podman) exec -it mimic-postgres \
psql -U mimic_app -d mimic \
-c "CREATE ROLE mimic_audit_writer LOGIN PASSWORD 'pick-a-dev-secret';"
```

View File

@@ -12,7 +12,8 @@ services:
volumes:
- mimic_pgdata:/var/lib/postgresql/data
# The `mimic_audit_writer` role is provisioned by the Ansible playbook
# in prod (D-010). For dev, create it manually after `make db-up`:
# in prod (D-010). For dev, create it manually after `make db-up`
# (substitute `podman` for `docker` if your runtime is Podman):
# docker exec -it mimic-postgres psql -U mimic_app -d mimic \
# -c "CREATE ROLE mimic_audit_writer LOGIN PASSWORD '<choose one>';"
# Then expose the same secret in MIMIC_DATABASE_AUDIT_URL in your .env.

View File

@@ -13,6 +13,7 @@ authors = [{ name = "RT" }]
dependencies = [
"flask>=3.0,<4.0",
"flask-cors>=4.0,<6.0",
"flask-socketio>=5.3,<6.0",
"flask-login>=0.6.3,<1.0",
"flask-migrate>=4.0,<5.0",
@@ -115,6 +116,7 @@ module = [
"flask_socketio.*",
"flask_migrate.*",
"flask_login.*",
"flask_cors.*",
"pythonjsonlogger.*",
"gevent.*",
"testcontainers.*",

View File

@@ -4,14 +4,20 @@ from __future__ import annotations
from flask import Flask
from mimic.api.audit import bp as audit_bp
from mimic.api.auth import bp as auth_bp
from mimic.api.engagements import bp as engagements_bp
from mimic.api.hosts import bp as hosts_bp
from mimic.api.scenarios import bp as scenarios_bp
from mimic.api.ttps import bp as ttps_bp
from mimic.api.users import bp as users_bp
def register_blueprints(app: Flask) -> None:
app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")
app.register_blueprint(users_bp, url_prefix="/api/v1/users")
app.register_blueprint(engagements_bp, url_prefix="/api/v1/engagements")
app.register_blueprint(hosts_bp, url_prefix="/api/v1")
app.register_blueprint(ttps_bp, url_prefix="/api/v1/library/ttps")
app.register_blueprint(scenarios_bp, url_prefix="/api/v1")
app.register_blueprint(audit_bp, url_prefix="/api/v1/audit")

View File

@@ -11,8 +11,11 @@ from pydantic import BaseModel, ValidationError
from sqlalchemy.orm import Session
from mimic.audit import AuditWriter
from mimic.auth.identity import AuthUser
from mimic.extensions import db
from mimic.rbac.matrix import GroupName
from mimic.schemas import CurrentUser
from mimic.schemas.pagination import PageQuery
def parse_body[T: BaseModel](model: type[T]) -> T:
@@ -50,6 +53,32 @@ def is_rt_lead() -> bool:
return GroupName.RT_LEAD.value in groups
def serialize_current_user(user: AuthUser) -> CurrentUser:
"""Build the API response describing the authenticated principal."""
return CurrentUser(
user_id=user.id,
username=user.email,
display_name=user.display_name,
role=user.user_type,
permissions=sorted(p.value for p in user.permissions),
groups=sorted(user.groups),
)
def api_error(code: str, message: str, status: int) -> tuple[Response, int]:
"""Uniform error envelope: `{error: <code>, message: <human>}`."""
return jsonify({"error": code, "message": message}), status
def parse_page_query() -> PageQuery:
"""Read `?page=` / `?page_size=` from the current request (D-016)."""
raw = {key: value for key, value in request.args.items() if key in {"page", "page_size"}}
try:
return PageQuery.model_validate(raw)
except ValidationError as exc:
abort(422, description=exc.errors())
def audit_write(
*,
action: str,

View File

@@ -0,0 +1,83 @@
"""Audit log viewer (rt_lead only — F11 AUDIT_READ)."""
from __future__ import annotations
from datetime import datetime
from flask import Blueprint, abort, jsonify, request
from flask.typing import ResponseReturnValue
from sqlalchemy import func, select
from sqlalchemy.sql.elements import ColumnElement
from mimic.api._helpers import parse_page_query, parse_uuid
from mimic.db.models import AuditLog
from mimic.extensions import db
from mimic.rbac import Permission, require_perm
from mimic.schemas import AuditLogEntry, Page
bp = Blueprint("audit", __name__)
@bp.get("/log")
@require_perm(Permission.AUDIT_READ)
def list_audit_log() -> ResponseReturnValue:
page_query = parse_page_query()
filters = _parse_filters()
base = select(AuditLog)
for clause in filters:
base = base.where(clause)
total = db.session.execute(select(func.count()).select_from(base.subquery())).scalar_one()
rows = (
db.session.execute(
base.order_by(AuditLog.ts.desc()).offset(page_query.offset).limit(page_query.limit)
)
.scalars()
.all()
)
page = Page[AuditLogEntry](
items=[AuditLogEntry.model_validate(row) for row in rows],
total=total,
page=page_query.page,
page_size=page_query.page_size,
)
return jsonify(page.model_dump(mode="json"))
def _parse_filters() -> list[ColumnElement[bool]]:
"""Translate the query string into SQLAlchemy where-clauses.
Supported filters: `action`, `actor_id`, `resource_type`, `since`, `until`.
Times use ISO 8601; invalid inputs yield a 422 through the global handler.
"""
clauses: list[ColumnElement[bool]] = []
args = request.args
if (action := args.get("action")) is not None:
clauses.append(AuditLog.action == action)
if (resource_type := args.get("resource_type")) is not None:
clauses.append(AuditLog.resource_type == resource_type)
if (actor_id := args.get("actor_id")) is not None:
clauses.append(AuditLog.actor_id == parse_uuid(actor_id, field="actor_id"))
if (since := args.get("since")) is not None:
clauses.append(AuditLog.ts >= _parse_iso(since, "since"))
if (until := args.get("until")) is not None:
clauses.append(AuditLog.ts <= _parse_iso(until, "until"))
return clauses
def _parse_iso(raw: str, field: str) -> datetime:
try:
return datetime.fromisoformat(raw)
except ValueError:
abort(
422,
description=[
{"loc": [field], "msg": "invalid ISO 8601 datetime", "type": "value_error"}
],
)
__all__ = ["bp"]

View File

@@ -0,0 +1,101 @@
"""Authentication endpoints (local password v1, sprint 1)."""
from __future__ import annotations
from datetime import UTC, datetime
from flask import Blueprint, session
from flask.typing import ResponseReturnValue
from flask_login import current_user, login_required, login_user, logout_user
from sqlalchemy import select
from sqlalchemy.orm import selectinload
from mimic.api._helpers import (
api_error,
audit_write,
jsonify_model,
parse_body,
serialize_current_user,
)
from mimic.auth.identity import AuthUser, authuser_from_orm
from mimic.auth.password import check_password
from mimic.db.models import User, UserGroup
from mimic.extensions import db
from mimic.schemas import LoginRequest
bp = Blueprint("auth", __name__)
@bp.post("/login")
def login() -> ResponseReturnValue:
"""Validate local credentials and start a Flask session.
Uniform 401 on bad credentials (no leak between "unknown user" and
"wrong password") and on disabled accounts.
"""
payload = parse_body(LoginRequest)
stmt = (
select(User)
.where(User.email == payload.username)
.options(selectinload(User.group_links).selectinload(UserGroup.group))
)
user = db.session.execute(stmt).scalar_one_or_none()
if user is None or not user.is_active:
# Run a bcrypt round anyway to flatten the timing signal between
# "unknown user" and "wrong password".
check_password(payload.password, "$2b$12$" + "x" * 53)
return api_error("invalid_credentials", "invalid username or password", 401)
if not check_password(payload.password, user.local_password_hash):
return api_error("invalid_credentials", "invalid username or password", 401)
user.last_login_at = datetime.now(tz=UTC)
db.session.commit()
auth_user = authuser_from_orm(user)
login_user(auth_user)
session.permanent = True
audit_write(
action="auth.login",
resource_type="user",
resource_id=user.id,
metadata={"username": user.email},
)
return jsonify_model(serialize_current_user(auth_user))
@bp.post("/logout")
@login_required # type: ignore[untyped-decorator]
def logout() -> ResponseReturnValue:
"""Clear the Flask session."""
user_id = getattr(current_user, "id", None)
logout_user()
if user_id is not None:
audit_write(
action="auth.logout",
resource_type="user",
resource_id=user_id,
)
return "", 204
@bp.get("/me")
def me() -> ResponseReturnValue:
"""Return the current principal, or 401 if anonymous.
Frontend calls this at boot to rehydrate the session.
"""
if not getattr(current_user, "is_authenticated", False):
return api_error("not_authenticated", "no active session", 401)
return jsonify_model(serialize_current_user(_as_authuser(current_user)))
def _as_authuser(principal: object) -> AuthUser:
"""Narrow Flask-Login's `current_user` proxy back to `AuthUser`."""
if not isinstance(principal, AuthUser):
raise TypeError("current_user is not an AuthUser")
return principal

View File

@@ -16,11 +16,17 @@ from mimic.api._helpers import (
parse_body,
parse_uuid,
)
from mimic.db.models import Engagement, EngagementMember
from mimic.db.models import Engagement, EngagementMember, User
from mimic.db.types import EngagementStatus
from mimic.extensions import db
from mimic.rbac import Permission, require_perm
from mimic.schemas import EngagementCreate, EngagementRead, EngagementUpdate
from mimic.schemas import (
EngagementCreate,
EngagementMemberCreate,
EngagementMemberRead,
EngagementRead,
EngagementUpdate,
)
bp = Blueprint("engagements", __name__)
@@ -123,3 +129,92 @@ def delete_engagement(eid: str) -> ResponseReturnValue:
resource_id=engagement.id,
)
return "", 204
# ---------------------------------------------------------------- members
# `_engagement_or_404` runs BEFORE any membership query so a non-member RT
# operator gets the same 404 as a non-existent engagement (spec-analyst
# anti-enumeration requirement).
@bp.get("/<eid>/members")
@require_perm(Permission.ENGAGEMENT_READ)
def list_members(eid: str) -> ResponseReturnValue:
engagement = _engagement_or_404(eid)
rows = (
db.session.execute(
select(EngagementMember)
.where(EngagementMember.engagement_id == engagement.id)
.order_by(EngagementMember.added_at)
)
.scalars()
.all()
)
return jsonify(
[EngagementMemberRead.model_validate(row).model_dump(mode="json") for row in rows]
)
@bp.post("/<eid>/members")
@require_perm(Permission.ENGAGEMENT_MEMBER_MANAGE)
def add_member(eid: str) -> ResponseReturnValue:
engagement = _engagement_or_404(eid)
payload = parse_body(EngagementMemberCreate)
user = db.session.get(User, payload.user_id)
if user is None or not user.is_active:
abort(404, description="user not found")
existing = db.session.execute(
select(EngagementMember).where(
EngagementMember.engagement_id == engagement.id,
EngagementMember.user_id == payload.user_id,
)
).scalar_one_or_none()
if existing is not None:
from mimic.api._helpers import api_error # noqa: PLC0415
return api_error("already_member", "user is already a member of this engagement", 409)
member = EngagementMember(
engagement_id=engagement.id,
user_id=payload.user_id,
role=payload.role,
)
db.session.add(member)
db.session.commit()
audit_write(
action="engagement_member.add",
resource_type="engagement_member",
resource_id=f"{engagement.id}:{payload.user_id}",
metadata={
"engagement_id": str(engagement.id),
"user_id": str(payload.user_id),
"role": payload.role,
},
)
return jsonify_model(EngagementMemberRead.model_validate(member), status=201)
@bp.delete("/<eid>/members/<uid>")
@require_perm(Permission.ENGAGEMENT_MEMBER_MANAGE)
def remove_member(eid: str, uid: str) -> ResponseReturnValue:
engagement = _engagement_or_404(eid)
user_uuid = parse_uuid(uid, field="user id")
member = db.session.execute(
select(EngagementMember).where(
EngagementMember.engagement_id == engagement.id,
EngagementMember.user_id == user_uuid,
)
).scalar_one_or_none()
if member is None:
abort(404)
db.session.delete(member)
db.session.commit()
audit_write(
action="engagement_member.remove",
resource_type="engagement_member",
resource_id=f"{engagement.id}:{user_uuid}",
metadata={"engagement_id": str(engagement.id), "user_id": str(user_uuid)},
)
return "", 204

View File

@@ -0,0 +1,187 @@
"""User management endpoints (rt_lead only — D-015).
All four routes require `USER_MANAGE`. The `DELETE` endpoint is a soft-disable
(sets `disabled_at`); we never hard-delete users so the audit trail and
authored resources keep their FK targets.
"""
from __future__ import annotations
from datetime import UTC, datetime
from flask import Blueprint, abort, jsonify
from flask.typing import ResponseReturnValue
from sqlalchemy import func, select
from sqlalchemy.orm import selectinload
from mimic.api._helpers import (
api_error,
audit_write,
jsonify_model,
parse_body,
parse_page_query,
parse_uuid,
)
from mimic.auth.password import hash_password
from mimic.db.models import Group, User, UserGroup
from mimic.db.types import UserType
from mimic.extensions import db
from mimic.rbac import Permission, require_perm
from mimic.rbac.matrix import GroupName
from mimic.schemas import Page, UserCreate, UserRead, UserUpdate
bp = Blueprint("users", __name__)
_TYPE_TO_GROUP: dict[UserType, GroupName] = {
UserType.RT_OPERATOR: GroupName.RT_OPERATOR,
UserType.RT_LEAD: GroupName.RT_LEAD,
UserType.SOC_ANALYST: GroupName.SOC_ANALYST,
}
def _resolve_group(user_type: UserType) -> Group:
group_name = _TYPE_TO_GROUP[user_type]
group = db.session.execute(
select(Group).where(Group.name == group_name.value)
).scalar_one_or_none()
if group is None:
abort(500, description=f"group {group_name.value!r} missing; run migrations")
return group
@bp.get("")
@require_perm(Permission.USER_MANAGE)
def list_users() -> ResponseReturnValue:
page_query = parse_page_query()
type_filter = _parse_type_filter()
base = select(User)
if type_filter is not None:
base = base.where(User.type == type_filter)
total = db.session.execute(select(func.count()).select_from(base.subquery())).scalar_one()
rows = (
db.session.execute(
base.order_by(User.created_at.desc()).offset(page_query.offset).limit(page_query.limit)
)
.scalars()
.all()
)
page = Page[UserRead](
items=[UserRead.model_validate(row) for row in rows],
total=total,
page=page_query.page,
page_size=page_query.page_size,
)
return jsonify(page.model_dump(mode="json"))
def _parse_type_filter() -> UserType | None:
from flask import request # noqa: PLC0415 — narrow to keep module import lean
raw = request.args.get("type")
if raw is None:
return None
try:
return UserType(raw)
except ValueError:
abort(422, description=[{"loc": ["type"], "msg": "invalid user type", "type": "enum"}])
@bp.post("")
@require_perm(Permission.USER_MANAGE)
def create_user() -> ResponseReturnValue:
payload = parse_body(UserCreate)
existing = db.session.execute(
select(User).where(User.email == payload.email)
).scalar_one_or_none()
if existing is not None:
return api_error("email_taken", "user with this email already exists", 409)
user = User(
email=payload.email,
display_name=payload.display_name,
type=payload.type,
local_password_hash=hash_password(payload.password),
)
db.session.add(user)
db.session.flush()
group = _resolve_group(payload.type)
db.session.add(UserGroup(user_id=user.id, group_id=group.id, engagement_id=None))
db.session.commit()
audit_write(
action="user.create",
resource_type="user",
resource_id=user.id,
metadata={"email": user.email, "type": user.type.value},
)
return jsonify_model(UserRead.model_validate(user), status=201)
@bp.patch("/<uid>")
@require_perm(Permission.USER_MANAGE)
def update_user(uid: str) -> ResponseReturnValue:
user = _user_or_404(uid)
payload = parse_body(UserUpdate)
changes = payload.model_dump(exclude_unset=True)
type_changed = "type" in changes and changes["type"] != user.type
if "display_name" in changes:
user.display_name = changes["display_name"]
if "password" in changes and changes["password"] is not None:
user.local_password_hash = hash_password(changes["password"])
if type_changed:
user.type = changes["type"]
# Realign group membership: drop the previous global membership and
# attach a fresh one matching the new type. Per-engagement memberships
# (engagement_id IS NOT NULL) stay untouched.
for link in list(user.group_links):
if link.engagement_id is None:
db.session.delete(link)
new_group = _resolve_group(changes["type"])
db.session.add(UserGroup(user_id=user.id, group_id=new_group.id, engagement_id=None))
db.session.commit()
audit_write(
action="user.update",
resource_type="user",
resource_id=user.id,
metadata={
"fields": sorted(k for k in changes if k != "password"),
"password_rotated": "password" in changes and changes["password"] is not None,
},
)
return jsonify_model(UserRead.model_validate(user))
@bp.delete("/<uid>")
@require_perm(Permission.USER_MANAGE)
def disable_user(uid: str) -> ResponseReturnValue:
user = _user_or_404(uid)
if user.disabled_at is not None:
return "", 204 # idempotent — already disabled
user.disabled_at = datetime.now(tz=UTC)
db.session.commit()
audit_write(
action="user.disable",
resource_type="user",
resource_id=user.id,
metadata={"email": user.email},
)
return "", 204
def _user_or_404(uid: str) -> User:
user = db.session.execute(
select(User)
.where(User.id == parse_uuid(uid, field="user id"))
.options(selectinload(User.group_links))
).scalar_one_or_none()
if user is None:
abort(404)
return user

View File

@@ -3,9 +3,11 @@
from __future__ import annotations
from datetime import timedelta
from typing import cast
from flask import Flask, jsonify
from flask.typing import ResponseReturnValue
from werkzeug.exceptions import HTTPException
from mimic.api import register_blueprints
from mimic.auth.identity import load_user
@@ -13,12 +15,32 @@ from mimic.config import Settings, get_settings
from mimic.extensions import db, login_manager, migrate, socketio
from mimic.logging import configure_logging
# HTTP status → stable snake_case error code surfaced in the JSON envelope.
# Anything not listed falls back to "http_error".
_HTTP_ERROR_CODES: dict[int, str] = {
400: "bad_request",
401: "not_authenticated",
403: "forbidden",
404: "not_found",
405: "method_not_allowed",
409: "conflict",
415: "unsupported_media_type",
422: "validation_error",
429: "rate_limited",
500: "internal_error",
503: "service_unavailable",
}
def create_app(settings: Settings | None = None) -> Flask:
settings = settings or get_settings()
configure_logging(settings.log_level, as_json=settings.log_json)
app = Flask(__name__)
# `strict_slashes=False` means routes match with or without the trailing
# slash. Cross-origin clients keep their session cookie either way (a
# 308 redirect could drop it on some browsers).
app.url_map.strict_slashes = False
app.config.update(
SECRET_KEY=settings.secret_key.get_secret_value(),
SQLALCHEMY_DATABASE_URI=settings.database_url,
@@ -35,12 +57,53 @@ def create_app(settings: Settings | None = None) -> Flask:
login_manager.init_app(app)
login_manager.user_loader(load_user)
@login_manager.unauthorized_handler # type: ignore[untyped-decorator]
def _unauthorized() -> ResponseReturnValue:
# API returns JSON; never redirect to a login page.
return (
jsonify({"error": "not_authenticated", "message": "no active session"}),
401,
)
@app.errorhandler(HTTPException)
def _json_http_error(exc: HTTPException) -> ResponseReturnValue:
"""Serialize every aborted request as the uniform JSON envelope.
`flask.abort()` defaults to a Werkzeug HTML page; without this handler
the contract documented in docs/api.md would only hold for 401s.
"""
status = exc.code or 500
# The Werkzeug type stub pins `description` to `str | None`, but
# `flask.abort(..., description=<list|dict>)` legally smuggles richer
# payloads through (we use this for Pydantic `errors()` on 422). Cast
# to `object` so the runtime type-narrowing below is type-checked.
description = cast(object, exc.description)
message: str
details: object | None = None
if isinstance(description, str):
message = description
elif isinstance(description, list | dict):
# Pydantic `exc.errors()` flows through `abort(422, description=...)`
# as a list; keep it under `details` so the client can map per-field.
message = "request failed"
details = description
else:
message = exc.name or "request failed"
body: dict[str, object] = {
"error": _HTTP_ERROR_CODES.get(status, "http_error"),
"message": message,
}
if details is not None:
body["details"] = details
return jsonify(body), status
socketio.init_app(
app,
cors_allowed_origins=settings.cors_origins or "*",
async_mode="gevent",
)
_enable_cors_in_dev(app, settings)
register_blueprints(app)
@app.get("/healthz")
@@ -48,3 +111,25 @@ def create_app(settings: Settings | None = None) -> Flask:
return jsonify(status="ok"), 200
return app
def _enable_cors_in_dev(app: Flask, settings: Settings) -> None:
"""Dev-only CORS for the Vite frontend on http://localhost:5173.
In production, the reverse proxy (Caddy + same-origin) terminates this
concern; enabling CORS there would expand the CSRF surface for no benefit.
"""
if settings.env != "development":
return
if not settings.cors_origins:
return
from flask_cors import CORS # noqa: PLC0415 — keeps the prod import path lean
CORS(
app,
resources={r"/api/*": {"origins": settings.cors_origins}},
supports_credentials=True,
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-Requested-With"],
max_age=600,
)

View File

@@ -9,6 +9,7 @@ from sqlalchemy import select
from sqlalchemy.orm import selectinload
from mimic.db.models import User, UserGroup
from mimic.db.types import UserType
from mimic.extensions import db
from mimic.rbac.matrix import GROUP_PERMISSIONS, GroupName, Permission
@@ -19,6 +20,8 @@ class AuthUser:
id: UUID
email: str
display_name: str | None = None
user_type: UserType = UserType.RT_OPERATOR
permissions: frozenset[Permission] = field(default_factory=frozenset)
groups: frozenset[str] = field(default_factory=frozenset)
is_authenticated: bool = True
@@ -29,6 +32,32 @@ class AuthUser:
return str(self.id)
def _resolve_permissions(group_names: set[str]) -> set[Permission]:
perms: set[Permission] = set()
for group_name in group_names:
try:
perms.update(GROUP_PERMISSIONS[GroupName(group_name)])
except ValueError:
continue
return perms
def authuser_from_orm(user: User) -> AuthUser:
"""Build an `AuthUser` from a refreshed `User` ORM row.
Caller must ensure `user.group_links` is loaded (selectinload or eager).
"""
group_names = {link.group.name for link in user.group_links}
return AuthUser(
id=user.id,
email=user.email,
display_name=user.display_name,
user_type=user.type,
permissions=frozenset(_resolve_permissions(group_names)),
groups=frozenset(group_names),
)
def load_user(user_id: str) -> AuthUser | None:
"""Flask-Login `user_loader` callback."""
try:
@@ -44,17 +73,4 @@ def load_user(user_id: str) -> AuthUser | None:
user = db.session.execute(stmt).scalar_one_or_none()
if user is None or not user.is_active:
return None
group_names = {link.group.name for link in user.group_links}
perms: set[Permission] = set()
for group_name in group_names:
try:
perms.update(GROUP_PERMISSIONS[GroupName(group_name)])
except ValueError:
continue
return AuthUser(
id=user.id,
email=user.email,
permissions=frozenset(perms),
groups=frozenset(group_names),
)
return authuser_from_orm(user)

View File

@@ -0,0 +1,75 @@
"""add `user.manage` permission + link to rt_lead (D-015)
Revision ID: 202605230001
Revises: 202605210001
Create Date: 2026-05-23
"""
from __future__ import annotations
from uuid import NAMESPACE_DNS, uuid5
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects.postgresql import UUID
revision: str = "202605230001"
down_revision: str | None = "202605210001"
branch_labels: str | None = None
depends_on: str | None = None
_PERMISSION_CODE = "user.manage"
_GROUP_NAME = "rt_lead"
# Frozen delta exposed for the migration-seed parity test
# (see tests/unit/test_migration_seed.py). The runtime matrix must equal the
# union of the initial migration freeze + every subsequent migration delta.
_DELTA_PERMISSIONS: tuple[str, ...] = (_PERMISSION_CODE,)
_DELTA_GROUP_PERMISSIONS: dict[str, frozenset[str]] = {
_GROUP_NAME: frozenset({_PERMISSION_CODE}),
}
def _pid(code: str) -> str:
"""Same hashing scheme as the initial-schema seed (frozen scheme)."""
return str(uuid5(NAMESPACE_DNS, f"mimic.permission.{code}"))
def _gid(name: str) -> str:
return str(uuid5(NAMESPACE_DNS, f"mimic.group.{name}"))
def upgrade() -> None:
permission_table = sa.table(
"permission",
sa.column("id", UUID(as_uuid=True)),
sa.column("code", sa.String),
sa.column("description", sa.String),
)
op.bulk_insert(
permission_table,
[{"id": _pid(_PERMISSION_CODE), "code": _PERMISSION_CODE, "description": None}],
)
group_permission_table = sa.table(
"group_permission",
sa.column("group_id", UUID(as_uuid=True)),
sa.column("permission_id", UUID(as_uuid=True)),
)
op.bulk_insert(
group_permission_table,
[{"group_id": _gid(_GROUP_NAME), "permission_id": _pid(_PERMISSION_CODE)}],
)
def downgrade() -> None:
bind = op.get_bind()
bind.exec_driver_sql(
sa.text("DELETE FROM group_permission WHERE permission_id = :pid").bindparams(
pid=_pid(_PERMISSION_CODE)
)
)
bind.exec_driver_sql(
sa.text("DELETE FROM permission WHERE code = :code").bindparams(code=_PERMISSION_CODE)
)

View File

@@ -54,6 +54,9 @@ class Permission(enum.StrEnum):
# Audit
AUDIT_READ = "audit.read"
# User management (D-015): gates all /api/v1/users CRUD. rt_lead only.
USER_MANAGE = "user.manage"
ALL_PERMISSIONS: tuple[Permission, ...] = tuple(Permission)

View File

@@ -1,11 +1,15 @@
"""Pydantic 2 request/response DTOs."""
from mimic.schemas.audit import AuditLogEntry
from mimic.schemas.auth import CurrentUser, LoginRequest
from mimic.schemas.engagement import (
EngagementCreate,
EngagementRead,
EngagementUpdate,
)
from mimic.schemas.engagement_member import EngagementMemberCreate, EngagementMemberRead
from mimic.schemas.host import HostCreate, HostRead, HostUpdate
from mimic.schemas.pagination import Page, PageQuery
from mimic.schemas.scenario import (
ScenarioCreate,
ScenarioRead,
@@ -14,14 +18,22 @@ from mimic.schemas.scenario import (
ScenarioUpdate,
)
from mimic.schemas.ttp import TtpCreate, TtpRead, TtpUpdate
from mimic.schemas.user import UserCreate, UserRead, UserUpdate
__all__ = [
"AuditLogEntry",
"CurrentUser",
"EngagementCreate",
"EngagementMemberCreate",
"EngagementMemberRead",
"EngagementRead",
"EngagementUpdate",
"HostCreate",
"HostRead",
"HostUpdate",
"LoginRequest",
"Page",
"PageQuery",
"ScenarioCreate",
"ScenarioRead",
"ScenarioStepCreate",
@@ -30,4 +42,7 @@ __all__ = [
"TtpCreate",
"TtpRead",
"TtpUpdate",
"UserCreate",
"UserRead",
"UserUpdate",
]

View File

@@ -0,0 +1,28 @@
"""Audit log viewer DTO (sprint 2)."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class AuditLogEntry(BaseModel):
"""Single audit log row as exposed by `GET /api/v1/audit/log`."""
model_config = ConfigDict(from_attributes=True)
id: UUID
ts: datetime
actor_id: UUID | None
action: str
resource_type: str
resource_id: str | None
metadata_json: dict[str, Any]
prev_hash: str | None
row_hash: str
source_ip: str | None
user_agent: str | None
comment: str | None

View File

@@ -0,0 +1,32 @@
"""Auth DTOs (login / logout / me)."""
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel, Field
from mimic.db.types import UserType
class LoginRequest(BaseModel):
"""Credentials posted to `/api/v1/auth/login`.
`username` is mapped to the `user.email` column server-side; the frontend
label remains generic so future identity sources (e.g. Keycloak `preferred_
username`) can route through the same endpoint.
"""
username: str = Field(min_length=1, max_length=255)
password: str = Field(min_length=1, max_length=512)
class CurrentUser(BaseModel):
"""Response shape for `/login`, `/me`."""
user_id: UUID
username: str
display_name: str | None
role: UserType
permissions: list[str]
groups: list[str]

View File

@@ -0,0 +1,27 @@
"""Engagement membership DTOs (sprint 2).
`role` is a free-form label per D-017 — not a permission gate. Application-
level RBAC stays the responsibility of the F11 `group` membership; per-
engagement role is informational (e.g. "lead", "shadow", "binôme A").
"""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
class EngagementMemberRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
engagement_id: UUID
user_id: UUID
role: str
added_at: datetime
class EngagementMemberCreate(BaseModel):
user_id: UUID
role: str = Field(default="member", min_length=1, max_length=40)

View File

@@ -0,0 +1,40 @@
"""Generic pagination envelope (D-016).
Frontend reads `{items, total, page, page_size}`. Sprint 2 uses this on
`/users` and `/audit/log`; existing endpoints (`/engagements`) stay
non-paginated for backwards-compatibility and will migrate together in a
later opt-in (`?paginate=true` or `/api/v2/`).
"""
from __future__ import annotations
from pydantic import BaseModel, ConfigDict, Field
DEFAULT_PAGE_SIZE = 50
MAX_PAGE_SIZE = 200
class Page[T](BaseModel):
"""Paginated response envelope."""
model_config = ConfigDict(arbitrary_types_allowed=True)
items: list[T]
total: int = Field(ge=0)
page: int = Field(ge=1)
page_size: int = Field(ge=1, le=MAX_PAGE_SIZE)
class PageQuery(BaseModel):
"""Parsed `?page=` / `?page_size=` query string (always normalised)."""
page: int = Field(default=1, ge=1)
page_size: int = Field(default=DEFAULT_PAGE_SIZE, ge=1, le=MAX_PAGE_SIZE)
@property
def offset(self) -> int:
return (self.page - 1) * self.page_size
@property
def limit(self) -> int:
return self.page_size

View File

@@ -0,0 +1,41 @@
"""User management DTOs (sprint 2)."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from mimic.db.types import UserType
class UserRead(BaseModel):
"""Public user representation. Never exposes `local_password_hash`."""
model_config = ConfigDict(from_attributes=True)
id: UUID
email: str
display_name: str | None
type: UserType
disabled_at: datetime | None
last_login_at: datetime | None
created_at: datetime
class UserCreate(BaseModel):
"""`POST /api/v1/users` body."""
email: EmailStr
display_name: str | None = Field(default=None, max_length=120)
password: str = Field(min_length=8, max_length=128)
type: UserType
class UserUpdate(BaseModel):
"""`PATCH /api/v1/users/<uid>` body (all fields optional)."""
display_name: str | None = Field(default=None, max_length=120)
password: str | None = Field(default=None, min_length=8, max_length=128)
type: UserType | None = None

View File

@@ -0,0 +1,202 @@
"""End-to-end smoke: login → create engagement → list engagement (sprint 1).
Uses the testcontainers Postgres scaffold + Flask test client. Each test
seeds a single RT-lead user and signs in over the session-cookie surface
the frontend will consume.
"""
from __future__ import annotations
from uuid import UUID
import pytest
from mimic.auth.password import hash_password
from mimic.db.models import Group, User, UserGroup
from mimic.db.types import UserType
from mimic.rbac.matrix import GROUP_PERMISSIONS, GroupName, Permission
pytestmark = pytest.mark.integration
def _seed_rt_lead(
db,
email: str = "lead@example.org",
password: str = "lead-secret-1", # noqa: S107
) -> UUID:
"""Create an rt_lead user + the rt_lead group + the membership link."""
group = db.session.query(Group).filter_by(name=GroupName.RT_LEAD.value).first()
if group is None:
group = Group(name=GroupName.RT_LEAD.value, description="Red team lead")
db.session.add(group)
db.session.flush()
user = User(
email=email,
display_name="Lead",
type=UserType.RT_LEAD,
local_password_hash=hash_password(password, rounds=4),
)
db.session.add(user)
db.session.flush()
db.session.add(UserGroup(user_id=user.id, group_id=group.id, engagement_id=None))
db.session.commit()
return user.id
def test_login_then_create_and_list_engagement(app, client) -> None:
from mimic.extensions import db # noqa: PLC0415 (must follow app fixture)
with app.app_context():
_seed_rt_lead(db)
# 1. /me before login → 401, uniform envelope.
response = client.get("/api/v1/auth/me")
assert response.status_code == 401
body = response.get_json()
assert body == {"error": "not_authenticated", "message": "no active session"}
# 2. login → 200, body shape matches CurrentUser, session cookie set.
response = client.post(
"/api/v1/auth/login",
json={"username": "lead@example.org", "password": "lead-secret-1"},
)
assert response.status_code == 200
user_payload = response.get_json()
assert user_payload["username"] == "lead@example.org"
assert user_payload["role"] == "rt_lead"
assert Permission.ENGAGEMENT_CREATE.value in user_payload["permissions"]
assert sorted(user_payload["permissions"]) == sorted(
p.value for p in GROUP_PERMISSIONS[GroupName.RT_LEAD]
)
# 3. /me after login → 200, same shape.
response = client.get("/api/v1/auth/me")
assert response.status_code == 200
assert response.get_json()["username"] == "lead@example.org"
# 4. POST /engagements → 201, created_by_id is current user.
response = client.post(
"/api/v1/engagements/",
json={"client_name": "Acme Demo", "c2_type": "mythic"},
)
assert response.status_code == 201
engagement = response.get_json()
assert engagement["client_name"] == "Acme Demo"
# 5. GET /engagements lists it (RT lead sees everything).
response = client.get("/api/v1/engagements/")
assert response.status_code == 200
listing = response.get_json()
assert any(e["id"] == engagement["id"] for e in listing)
# 6. logout → 204; subsequent /me → 401.
response = client.post("/api/v1/auth/logout")
assert response.status_code == 204
response = client.get("/api/v1/auth/me")
assert response.status_code == 401
def test_login_rejects_bad_credentials(app, client) -> None:
from mimic.extensions import db # noqa: PLC0415
with app.app_context():
_seed_rt_lead(db, email="bob@example.org", password="hunter2")
# Wrong password.
response = client.post(
"/api/v1/auth/login",
json={"username": "bob@example.org", "password": "wrong"},
)
assert response.status_code == 401
assert response.get_json() == {
"error": "invalid_credentials",
"message": "invalid username or password",
}
# Unknown user — same uniform message (no enumeration leak).
response = client.post(
"/api/v1/auth/login",
json={"username": "ghost@example.org", "password": "hunter2"},
)
assert response.status_code == 401
assert response.get_json() == {
"error": "invalid_credentials",
"message": "invalid username or password",
}
def test_logout_without_session_returns_401(app, client) -> None:
response = client.post("/api/v1/auth/logout")
assert response.status_code == 401
assert response.get_json() == {"error": "not_authenticated", "message": "no active session"}
def test_engagements_route_accepts_both_slash_variants(app, client) -> None:
"""strict_slashes=False: `/engagements` and `/engagements/` both match
the same handler — no 308 redirect that would drop the session cookie
on some browsers (frontend fix request)."""
from mimic.extensions import db # noqa: PLC0415
with app.app_context():
_seed_rt_lead(db, email="dual@example.org", password="dual-slash-1")
client.post(
"/api/v1/auth/login",
json={"username": "dual@example.org", "password": "dual-slash-1"},
)
no_slash = client.get("/api/v1/engagements")
with_slash = client.get("/api/v1/engagements/")
assert no_slash.status_code == 200
assert with_slash.status_code == 200
assert no_slash.get_json() == with_slash.get_json()
def test_engagement_create_validation_error_returns_uniform_envelope(app, client) -> None:
"""Pydantic 422 must flow through the global handler with `details`
carrying the per-field error list (frontend uses it for form mapping)."""
from mimic.extensions import db # noqa: PLC0415
with app.app_context():
_seed_rt_lead(db, email="form@example.org", password="form-secret-1")
client.post(
"/api/v1/auth/login",
json={"username": "form@example.org", "password": "form-secret-1"},
)
response = client.post("/api/v1/engagements", json={"description": "no client name"})
assert response.status_code == 422
body = response.get_json()
assert body["error"] == "validation_error"
assert body["message"] == "request failed"
assert isinstance(body["details"], list)
locs = [tuple(entry["loc"]) for entry in body["details"]]
assert ("client_name",) in locs
def test_unknown_route_returns_uniform_404(app, client) -> None:
response = client.get("/api/v1/does-not-exist")
assert response.status_code == 404
body = response.get_json()
assert body["error"] == "not_found"
assert "message" in body
def test_bad_json_body_returns_uniform_400(app, client) -> None:
from mimic.extensions import db # noqa: PLC0415
with app.app_context():
_seed_rt_lead(db, email="raw@example.org", password="raw-secret-1")
client.post(
"/api/v1/auth/login",
json={"username": "raw@example.org", "password": "raw-secret-1"},
)
response = client.post(
"/api/v1/engagements",
data="not-json",
content_type="application/json",
)
assert response.status_code == 400
body = response.get_json()
assert body["error"] == "bad_request"
assert body["message"] == "JSON body required"

View File

@@ -0,0 +1,284 @@
"""End-to-end coverage of sprint 2:
- rt_lead creates an rt_operator via `POST /api/v1/users`.
- rt_lead assigns the operator to engagement A (`POST /engagements/A/members`).
- The operator signs in.
- The operator's `GET /engagements` listing shows A and NOT B.
- The operator's `GET /engagements/B` returns 404 (MA6: same 404 as if B
didn't exist — anti-leak).
- The operator's `GET /engagements/B/members` returns 404 too (anti-leak).
- The audit log records the chain (`user.create`, `engagement_member.add`,
`auth.login`).
"""
from __future__ import annotations
from uuid import UUID
import pytest
from mimic.auth.password import hash_password
from mimic.db.models import Engagement, Group, User, UserGroup
from mimic.db.types import C2Type, EngagementStatus, UserType
from mimic.rbac.matrix import GroupName
pytestmark = pytest.mark.integration
def _ensure_group(db, name: GroupName, description: str = "") -> Group:
group = db.session.query(Group).filter_by(name=name.value).first()
if group is None:
group = Group(name=name.value, description=description)
db.session.add(group)
db.session.flush()
return group
def _seed_rt_lead(db, email: str, password: str) -> UUID:
group = _ensure_group(db, GroupName.RT_LEAD, "Red team lead")
user = User(
email=email,
display_name="Lead",
type=UserType.RT_LEAD,
local_password_hash=hash_password(password, rounds=4),
)
db.session.add(user)
db.session.flush()
db.session.add(UserGroup(user_id=user.id, group_id=group.id, engagement_id=None))
db.session.commit()
return user.id
def _ensure_operator_group(db) -> None:
_ensure_group(db, GroupName.RT_OPERATOR, "Red team operator")
def _create_engagement(db, client_name: str) -> UUID:
engagement = Engagement(
client_name=client_name, c2_type=C2Type.MYTHIC, status=EngagementStatus.DRAFT
)
db.session.add(engagement)
db.session.commit()
return engagement.id
def test_lead_creates_operator_assigns_engagement_a_scope_isolates(app, client) -> None:
from mimic.extensions import db # noqa: PLC0415
with app.app_context():
_seed_rt_lead(db, "lead@example.org", "lead-secret-1")
_ensure_operator_group(db)
engagement_a = _create_engagement(db, "Acme A")
engagement_b = _create_engagement(db, "Acme B")
# rt_lead logs in.
response = client.post(
"/api/v1/auth/login",
json={"username": "lead@example.org", "password": "lead-secret-1"},
)
assert response.status_code == 200
# rt_lead creates an rt_operator user.
response = client.post(
"/api/v1/users",
json={
"email": "op@example.org",
"password": "operator-pw-1",
"type": "rt_operator",
"display_name": "Op One",
},
)
assert response.status_code == 201, response.get_json()
operator_id = response.get_json()["id"]
# rt_lead assigns the operator to engagement A only.
response = client.post(
f"/api/v1/engagements/{engagement_a}/members",
json={"user_id": operator_id, "role": "binôme"},
)
assert response.status_code == 201
# rt_lead listing /users contains the operator.
response = client.get("/api/v1/users?type=rt_operator")
assert response.status_code == 200
body = response.get_json()
assert body["total"] == 1
assert body["items"][0]["email"] == "op@example.org"
# Logout the lead and log in as the operator.
client.post("/api/v1/auth/logout")
response = client.post(
"/api/v1/auth/login",
json={"username": "op@example.org", "password": "operator-pw-1"},
)
assert response.status_code == 200
op_payload = response.get_json()
assert op_payload["role"] == "rt_operator"
# /engagements lists only A.
response = client.get("/api/v1/engagements")
assert response.status_code == 200
listing = response.get_json()
ids = {row["id"] for row in listing}
assert ids == {str(engagement_a)}
# /engagements/B → 404 (MA6 — anti-leak: same response as a non-existent id).
response = client.get(f"/api/v1/engagements/{engagement_b}")
assert response.status_code == 404
assert response.get_json()["error"] == "not_found"
# /engagements/B/members → 404 too (spec-analyst anti-enum requirement).
response = client.get(f"/api/v1/engagements/{engagement_b}/members")
assert response.status_code == 404
# /engagements/A is reachable for the operator.
response = client.get(f"/api/v1/engagements/{engagement_a}")
assert response.status_code == 200
# /engagements/A/members is reachable for the operator (they are a member).
response = client.get(f"/api/v1/engagements/{engagement_a}/members")
assert response.status_code == 200
members = response.get_json()
assert len(members) == 1
assert members[0]["user_id"] == operator_id
assert members[0]["role"] == "binôme"
# The operator cannot list /users (no USER_MANAGE permission).
response = client.get("/api/v1/users")
assert response.status_code == 403
def test_user_management_lead_only(app, client) -> None:
"""USER_MANAGE is rt_lead-only (D-015). An operator with a session gets
a clean 403 — and an anonymous request gets a 401."""
from mimic.extensions import db # noqa: PLC0415
with app.app_context():
_seed_rt_lead(db, "lead2@example.org", "lead2-secret-1")
_ensure_operator_group(db)
client.post(
"/api/v1/auth/login",
json={"username": "lead2@example.org", "password": "lead2-secret-1"},
)
# Create an operator user.
response = client.post(
"/api/v1/users",
json={
"email": "op2@example.org",
"password": "operator-pw-1",
"type": "rt_operator",
},
)
assert response.status_code == 201
client.post("/api/v1/auth/logout")
# Anonymous → 401.
response = client.get("/api/v1/users")
assert response.status_code == 401
# Operator session → 403.
client.post(
"/api/v1/auth/login",
json={"username": "op2@example.org", "password": "operator-pw-1"},
)
response = client.get("/api/v1/users")
assert response.status_code == 403
def test_create_user_email_taken_returns_409(app, client) -> None:
from mimic.extensions import db # noqa: PLC0415
with app.app_context():
_seed_rt_lead(db, "lead3@example.org", "lead3-secret-1")
_ensure_operator_group(db)
client.post(
"/api/v1/auth/login",
json={"username": "lead3@example.org", "password": "lead3-secret-1"},
)
body = {
"email": "dup@example.org",
"password": "longenough",
"type": "rt_operator",
}
assert client.post("/api/v1/users", json=body).status_code == 201
response = client.post("/api/v1/users", json=body)
assert response.status_code == 409
payload = response.get_json()
assert payload["error"] == "email_taken"
def test_audit_log_endpoint_is_lead_only_and_paginates(app, client) -> None:
from mimic.extensions import db # noqa: PLC0415
with app.app_context():
_seed_rt_lead(db, "lead4@example.org", "lead4-secret-1")
_ensure_operator_group(db)
client.post(
"/api/v1/auth/login",
json={"username": "lead4@example.org", "password": "lead4-secret-1"},
)
# Generate some audit activity.
for i in range(3):
client.post(
"/api/v1/users",
json={
"email": f"audit-op-{i}@example.org",
"password": "longenough",
"type": "rt_operator",
},
)
response = client.get("/api/v1/audit/log?page_size=2&action=user.create")
assert response.status_code == 200
body = response.get_json()
assert body["page_size"] == 2
assert body["page"] == 1
assert len(body["items"]) == 2
assert body["total"] >= 3
assert all(entry["action"] == "user.create" for entry in body["items"])
# Operator session → 403.
client.post("/api/v1/auth/logout")
client.post(
"/api/v1/auth/login",
json={"username": "audit-op-0@example.org", "password": "longenough"},
)
response = client.get("/api/v1/audit/log")
assert response.status_code == 403
def test_disable_user_blocks_future_login(app, client) -> None:
from mimic.extensions import db # noqa: PLC0415
with app.app_context():
_seed_rt_lead(db, "lead5@example.org", "lead5-secret-1")
_ensure_operator_group(db)
client.post(
"/api/v1/auth/login",
json={"username": "lead5@example.org", "password": "lead5-secret-1"},
)
response = client.post(
"/api/v1/users",
json={
"email": "soon-disabled@example.org",
"password": "longenough",
"type": "rt_operator",
},
)
assert response.status_code == 201
user_id = response.get_json()["id"]
response = client.delete(f"/api/v1/users/{user_id}")
assert response.status_code == 204
client.post("/api/v1/auth/logout")
response = client.post(
"/api/v1/auth/login",
json={"username": "soon-disabled@example.org", "password": "longenough"},
)
# Disabled accounts return the same uniform envelope as bad credentials.
assert response.status_code == 401
assert response.get_json()["error"] == "invalid_credentials"

View File

@@ -0,0 +1,71 @@
"""Auth DTOs + current-user serializer (no DB / no Flask context)."""
from __future__ import annotations
from uuid import uuid4
import pytest
from pydantic import ValidationError
from mimic.api._helpers import serialize_current_user
from mimic.auth.identity import AuthUser
from mimic.db.types import UserType
from mimic.rbac.matrix import GROUP_PERMISSIONS, GroupName, Permission
from mimic.schemas import CurrentUser, LoginRequest
def test_login_request_minimal_payload() -> None:
req = LoginRequest.model_validate({"username": "alice@x", "password": "hunter2"})
assert req.username == "alice@x"
assert req.password == "hunter2"
def test_login_request_rejects_empty() -> None:
with pytest.raises(ValidationError):
LoginRequest.model_validate({"username": "", "password": "x"})
with pytest.raises(ValidationError):
LoginRequest.model_validate({"username": "alice", "password": ""})
def test_login_request_rejects_unknown_fields() -> None:
# Pydantic 2 default behavior: extra fields are allowed but ignored.
# We assert the strict shape stays minimal by passing only known fields.
payload = {"username": "alice", "password": "x", "remember_me": True}
req = LoginRequest.model_validate(payload)
assert not hasattr(req, "remember_me")
def test_serialize_current_user_round_trip() -> None:
uid = uuid4()
auth_user = AuthUser(
id=uid,
email="lead@example.org",
display_name="Lead",
user_type=UserType.RT_LEAD,
permissions=frozenset(GROUP_PERMISSIONS[GroupName.RT_LEAD]),
groups=frozenset({GroupName.RT_LEAD.value}),
)
payload: CurrentUser = serialize_current_user(auth_user)
assert payload.user_id == uid
assert payload.username == "lead@example.org"
assert payload.display_name == "Lead"
assert payload.role is UserType.RT_LEAD
assert payload.groups == [GroupName.RT_LEAD.value]
# Permissions are sorted as their string values for stable client diffs.
assert payload.permissions == sorted(p.value for p in Permission)
def test_serialize_current_user_operator_subset() -> None:
auth_user = AuthUser(
id=uuid4(),
email="op@example.org",
user_type=UserType.RT_OPERATOR,
permissions=frozenset(GROUP_PERMISSIONS[GroupName.RT_OPERATOR]),
groups=frozenset({GroupName.RT_OPERATOR.value}),
)
payload = serialize_current_user(auth_user)
assert payload.role is UserType.RT_OPERATOR
assert payload.display_name is None
expected = sorted(p.value for p in GROUP_PERMISSIONS[GroupName.RT_OPERATOR])
assert payload.permissions == expected
assert Permission.RUN_CONTROL.value not in payload.permissions

View File

@@ -1,39 +1,72 @@
"""MA3: the frozen RBAC seed in the initial migration must keep matching
the runtime F11 matrix in `mimic.rbac.matrix`. When they drift, *do not* edit
the migration in place — write a new migration. This test enforces it.
"""Migration-seed parity test (MA3 + sprint 2 delta).
The runtime F11 matrix in `mimic.rbac.matrix` must equal the union of:
- the inline frozen snapshot in the initial migration `202605210001`, plus
- every per-migration `_DELTA_PERMISSIONS` / `_DELTA_GROUP_PERMISSIONS` added
by later migrations.
When the runtime drifts, *do not* edit an existing migration: write a new
one with its own delta block and extend the `_MIGRATIONS` tuple below.
"""
from __future__ import annotations
import importlib
from types import ModuleType
from mimic.rbac.matrix import GROUP_PERMISSIONS, GroupName, Permission
def _load_migration():
return importlib.import_module("mimic.db.migrations.versions.202605210001_initial_schema")
_INITIAL = "mimic.db.migrations.versions.202605210001_initial_schema"
_DELTAS: tuple[str, ...] = ("mimic.db.migrations.versions.202605230001_add_user_manage_permission",)
def test_frozen_permission_list_matches_runtime() -> None:
migration = _load_migration()
def _load(name: str) -> ModuleType:
return importlib.import_module(name)
def _cumulative_permissions() -> set[str]:
initial = _load(_INITIAL)
codes = set(initial._PERMISSIONS_FROZEN)
for name in _DELTAS:
delta = _load(name)
codes.update(delta._DELTA_PERMISSIONS)
return codes
def _cumulative_group_permissions() -> dict[str, set[str]]:
initial = _load(_INITIAL)
cumulative = {gn: set(perms) for gn, perms in initial._GROUP_PERMISSIONS_FROZEN.items()}
for name in _DELTAS:
delta = _load(name)
# `rt_lead` carries ALL_PERMISSIONS at runtime — every delta perm
# implicitly extends rt_lead too, regardless of whether the migration
# listed it explicitly.
rt_lead_implicit = {p for perms in delta._DELTA_GROUP_PERMISSIONS.values() for p in perms}
cumulative.setdefault("rt_lead", set()).update(rt_lead_implicit)
for group_name, perms in delta._DELTA_GROUP_PERMISSIONS.items():
cumulative.setdefault(group_name, set()).update(perms)
return cumulative
def test_runtime_permissions_match_cumulative_migrations() -> None:
runtime_codes = {p.value for p in Permission}
frozen_codes = set(migration._PERMISSIONS_FROZEN)
assert runtime_codes == frozen_codes, (
"Permission enum drifted from the migration freeze; "
"write a new migration, do not edit the existing one."
cumulative = _cumulative_permissions()
assert runtime_codes == cumulative, (
"Permission enum drifted from the cumulative migration freeze; "
"write a new migration delta, do not edit existing ones."
)
def test_frozen_group_membership_matches_runtime() -> None:
migration = _load_migration()
def test_runtime_group_membership_matches_cumulative_migrations() -> None:
runtime = {gn.value: {p.value for p in perms} for gn, perms in GROUP_PERMISSIONS.items()}
frozen = {gn: set(perms) for gn, perms in migration._GROUP_PERMISSIONS_FROZEN.items()}
assert runtime == frozen, (
"GROUP_PERMISSIONS drifted from the migration freeze; "
"write a new migration, do not edit the existing one."
cumulative = _cumulative_group_permissions()
assert runtime == cumulative, (
"GROUP_PERMISSIONS drifted from the cumulative migration freeze; "
"write a new migration delta, do not edit existing ones."
)
def test_frozen_group_names_match_enum() -> None:
migration = _load_migration()
assert set(migration._GROUP_PERMISSIONS_FROZEN.keys()) == {g.value for g in GroupName}
def test_initial_frozen_group_names_match_enum() -> None:
initial = _load(_INITIAL)
assert set(initial._GROUP_PERMISSIONS_FROZEN.keys()) == {g.value for g in GroupName}

View File

@@ -0,0 +1,94 @@
"""User/member DTO validation (no DB)."""
from __future__ import annotations
from uuid import uuid4
import pytest
from pydantic import ValidationError
from mimic.db.types import UserType
from mimic.schemas import EngagementMemberCreate, UserCreate, UserUpdate
from mimic.schemas.pagination import MAX_PAGE_SIZE, PageQuery
class TestUserCreate:
def test_minimal_valid_payload(self) -> None:
u = UserCreate.model_validate(
{
"email": "alice@example.org",
"password": "longenough",
"type": "rt_operator",
}
)
assert u.email == "alice@example.org"
assert u.type is UserType.RT_OPERATOR
assert u.display_name is None
def test_password_min_length(self) -> None:
with pytest.raises(ValidationError):
UserCreate.model_validate(
{"email": "a@b.c", "password": "short", "type": "rt_operator"}
)
def test_invalid_email_rejected(self) -> None:
with pytest.raises(ValidationError):
UserCreate.model_validate(
{"email": "not-an-email", "password": "longenough", "type": "rt_operator"}
)
def test_invalid_type_rejected(self) -> None:
with pytest.raises(ValidationError):
UserCreate.model_validate(
{"email": "a@b.c", "password": "longenough", "type": "not-a-role"}
)
class TestUserUpdate:
def test_all_optional(self) -> None:
u = UserUpdate.model_validate({})
assert u.display_name is None
assert u.password is None
assert u.type is None
def test_password_validates_when_provided(self) -> None:
with pytest.raises(ValidationError):
UserUpdate.model_validate({"password": "tiny"})
class TestEngagementMemberCreate:
def test_default_role(self) -> None:
member = EngagementMemberCreate.model_validate({"user_id": str(uuid4())})
assert member.role == "member"
def test_explicit_role(self) -> None:
member = EngagementMemberCreate.model_validate(
{"user_id": str(uuid4()), "role": "lead-on-mission"}
)
assert member.role == "lead-on-mission"
def test_role_max_length(self) -> None:
with pytest.raises(ValidationError):
EngagementMemberCreate.model_validate({"user_id": str(uuid4()), "role": "x" * 41})
class TestPageQuery:
def test_defaults(self) -> None:
page = PageQuery.model_validate({})
assert page.page == 1
assert page.page_size == 50
assert page.offset == 0
assert page.limit == 50
def test_offset_arithmetic(self) -> None:
page = PageQuery.model_validate({"page": 4, "page_size": 25})
assert page.offset == 75
assert page.limit == 25
def test_page_size_clamped(self) -> None:
with pytest.raises(ValidationError):
PageQuery.model_validate({"page_size": MAX_PAGE_SIZE + 1})
def test_page_lower_bound(self) -> None:
with pytest.raises(ValidationError):
PageQuery.model_validate({"page": 0})

366
docs/api.md Normal file
View File

@@ -0,0 +1,366 @@
# 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": "<snake_case_code>", "message": "<human>", "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/<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
### `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/<eid>`
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/<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
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.

View File

@@ -25,7 +25,7 @@ mimic/
Deployment artifacts (Ansible playbook, prod compose) live outside the repo
in the RT infra repo (D-010). Mimic ships only Dockerfiles and a dev
`docker-compose.yml`.
`compose.yml`.
## Backend module tree

274
docs/deploy.md Normal file
View File

@@ -0,0 +1,274 @@
# Mimic — production deployment
Operational guide for rolling Mimic out on the RT infrastructure. Scope is
the **application repo only** — the Ansible playbook that automates the
host preparation lives in the separate RT infra repository (D-010), and
the Caddy reverse proxy is owned by the RT platform (D-007). This document
references both without duplicating them.
For CI/runner setup, see [`docs/podman-runner-setup.md`](./podman-runner-setup.md).
For architectural context, see [`docs/architecture.md`](./architecture.md).
## Audience
Whoever pushes a new Mimic version to production. Assumes familiarity with
Podman rootless, systemd user units, and PostgreSQL DSN syntax.
## Host prerequisites
| Component | Version | Notes |
| --- | --- | --- |
| OS | Linux x86_64 | Tested on Debian 12 and Fedora 41. SELinux-aware. |
| Podman | ≥ 5.0 | Rootless mode mandatory. Verify with `podman info --format '{{.Host.Security.Rootless}}'` returns `true`. |
| systemd | user mode | `loginctl enable-linger <mimic-user>` so user services survive logout. |
| PostgreSQL | 16 | Reachable from the Mimic container. Local socket fine; networked instance fine. |
| Reverse proxy | Caddy (out-of-Mimic) | Provides TLS, IP allowlist, and SOC session token plumbing. Configured in the RT infra repo. |
The deployment user (referred to as `<mimic-user>` below) is typically a
dedicated `mimic` system account. Reusing the `gitea` user is acceptable
for single-tenant hosts but not recommended in multi-app scenarios.
## Filesystem layout
| Path | Owner | Mode | Purpose |
| --- | --- | --- | --- |
| `/var/lib/mimic/blobs` | `<mimic-user>:<mimic-user>` | `0750` | Content-addressed C2 output blobs (D-012). Default for `MIMIC_BLOB_ROOT`. |
| `/var/lib/mimic/evidence` | `<mimic-user>:<mimic-user>` | `0750` | User-uploaded evidence (F8). Default for `MIMIC_EVIDENCE_ROOT`. |
| `/var/log/mimic` | `<mimic-user>:<mimic-user>` | `0750` | Application logs if file-logging is enabled. JSON to stdout by default. |
| `~<mimic-user>/.config/containers/systemd/` | `<mimic-user>` | `0700` | Quadlet units for the backend + frontend containers. |
The Ansible playbook in the RT infra repo creates these paths with the
correct permissions. Manual provisioning equivalent:
```bash
sudo install -d -o <mimic-user> -g <mimic-user> -m 0750 \
/var/lib/mimic/blobs /var/lib/mimic/evidence /var/log/mimic
```
## Environment variables
Loaded from the systemd unit `Environment=` directives or a separate
`.env` file mounted into the container. All variables are prefixed
`MIMIC_` (Pydantic Settings convention, see `backend/src/mimic/config.py`).
### Required in production
| Variable | Example | Effect |
| --- | --- | --- |
| `MIMIC_ENV` | `production` | Switches default cookie / log behaviour. |
| `MIMIC_SECRET_KEY` | `$(python -c 'import secrets; print(secrets.token_urlsafe(32))')` | Flask session cookie HMAC. Rotating it invalidates every live session — schedule a maintenance window. |
| `MIMIC_FERNET_KEY` | `$(python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())')` | Symmetric key encrypting `c2_credential.config_json_fernet`. **Required** in prod. `Fernet(b"")` would crash on first credential decrypt; the empty default in `config.py` exists only so tests can boot. |
| `MIMIC_DATABASE_URL` | `postgresql+psycopg://mimic_app:<pw>@postgres:5432/mimic` | Main app DSN. The role behind it must NOT have `INSERT` on `audit_log` (NF-AUDIT append-only contract). |
| `MIMIC_DATABASE_AUDIT_URL` | `postgresql+psycopg://mimic_audit_writer:<pw>@postgres:5432/mimic` | Write-only DSN used by the audit writer. The role has `INSERT` on `audit_log` and nothing else. See [Bootstrap the audit role](#bootstrap-the-audit-role). |
### Required with safe defaults
| Variable | Default | Comment |
| --- | --- | --- |
| `MIMIC_BLOB_ROOT` | `/var/lib/mimic/blobs` | Override only if the data partition lives elsewhere. |
| `MIMIC_EVIDENCE_ROOT` | `/var/lib/mimic/evidence` | Same. |
| `MIMIC_SESSION_COOKIE_SECURE` | `true` | Must stay `true` behind Caddy/TLS. Set `false` only for the dev compose. |
| `MIMIC_SESSION_COOKIE_SAMESITE` | `Lax` | `Strict` if SOC console is on the same eTLD+1 as Mimic. |
| `MIMIC_LOG_LEVEL` | `INFO` | `DEBUG` is verbose, do not enable in prod without a reason. |
| `MIMIC_LOG_JSON` | `true` | Required for log shipping. Disable only for human debugging. |
| `MIMIC_CORS_ORIGINS` | `[]` (none) | Set to the public Mimic URL if frontend and backend are served from different origins. |
### Never set in production
`MIMIC_DATABASE_URL` and `MIMIC_DATABASE_AUDIT_URL` must point to two
different roles. Pointing them at the same role defeats the audit
append-only guarantee — caught by code review N5 (see
`tasks/todo.md` § CI follow-ups).
## Secrets management
Three secrets must never appear in container images, git history, or
agent transcripts: `MIMIC_SECRET_KEY`, `MIMIC_FERNET_KEY`, and the
PostgreSQL password embedded in the two DSNs.
Recommended flow (matches the team-wide "secrets via file, not chat"
convention):
1. Generate secrets once per environment on the deploy host:
```bash
umask 077
install -d -m 0700 ~/secrets
python -c 'import secrets; print(secrets.token_urlsafe(32))' > ~/secrets/SECRET_KEY
python -c 'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())' > ~/secrets/FERNET_KEY
```
2. Reference the files from the systemd unit via `EnvironmentFile=` (one
`KEY=VALUE` per line) **or** mount them as in-container files and
read them with `MIMIC_FERNET_KEY_FILE` equivalent indirection. Today
the app reads `MIMIC_FERNET_KEY` directly; the file-based path is
tracked as a follow-up.
3. Back up the secret material to the RT password vault, not anywhere
else. Losing `FERNET_KEY` after C2 credentials are persisted means
the data is permanently unreadable (no recovery key by design).
4. Rotating `MIMIC_FERNET_KEY` requires a re-encryption pass over
`c2_credential.config_json_fernet`. The Ansible playbook ships a
maintenance task for it; it is not exposed in the application CLI.
## Container images
| Component | Image | Tag policy |
| --- | --- | --- |
| Backend | `backend/Dockerfile`, built and pushed by CI | Pin `:X.Y.Z` per release. Never `:latest` in prod (follow-up F-D1). |
| Frontend | `frontend/Dockerfile`, built and pushed by CI | Same policy. Served by `nginxinc/nginx-unprivileged:alpine` listening on 8080. |
| PostgreSQL | `postgres:16-alpine` | Pin a minor tag (`16.4-alpine`) in production compose. |
The backend image listens on **5000** as user `mimic` (uid 1001). The
frontend image listens on **8080** as user `nginx` (uid 101).
## PostgreSQL setup
The application user (`mimic_app`) is created by the Ansible playbook
with `LOGIN` and ownership over the application database. It does **not**
get `INSERT` on `audit_log` — that grant goes to a separate role, see
below.
### Bootstrap the audit role
`mimic_audit_writer` exists to enforce the NF-AUDIT append-only contract.
The Alembic baseline migration grants `INSERT ON audit_log` to this role
if it exists, idempotently. Create the role before running migrations
(the Ansible playbook does this; manual equivalent):
```sql
-- run as a Postgres superuser, against the mimic database
CREATE ROLE mimic_audit_writer LOGIN PASSWORD '<paste-from-vault>';
```
Then expose its DSN as `MIMIC_DATABASE_AUDIT_URL`. The application boots
even if the role is missing (the grant block is a no-op), but every
audit write will fail at runtime — fail-loud preferred over silent data
loss.
### Apply migrations
The backend container runs Alembic at startup via its entrypoint, against
the `MIMIC_DATABASE_URL` DSN. To apply manually:
```bash
podman exec -it mimic-backend alembic upgrade head
```
A schema downgrade (rollback procedure below) uses the same surface in
reverse.
## Quadlet units
Both containers run under the `<mimic-user>` systemd user instance via
Quadlet. Example backend unit
(`~<mimic-user>/.config/containers/systemd/mimic-backend.container`):
```ini
[Unit]
Description=Mimic backend
After=network-online.target
[Container]
Image=registry.try2get.in/mimic-backend:X.Y.Z
ContainerName=mimic-backend
PublishPort=127.0.0.1:5000:5000
EnvironmentFile=%h/secrets/mimic-backend.env
Volume=/var/lib/mimic/blobs:/var/lib/mimic/blobs:Z
Volume=/var/lib/mimic/evidence:/var/lib/mimic/evidence:Z
[Service]
Restart=on-failure
RestartSec=5
[Install]
WantedBy=default.target
```
Frontend unit is structurally identical, listening on `127.0.0.1:8080`.
Caddy fronts both. Activation:
```bash
systemctl --user daemon-reload
systemctl --user enable --now mimic-backend.service mimic-frontend.service
```
The reverse proxy configuration on Caddy (out-of-Mimic) terminates TLS
and forwards `https://<mimic-domain>/api/*` → `127.0.0.1:5000`, every
other path → `127.0.0.1:8080`.
## Smoke validation
Once the stack is up:
```bash
# From the deploy host, behind Caddy
curl -fsS https://<mimic-domain>/healthz
# → "ok"
# Direct to the backend (should not be reachable externally — sanity)
curl -fsS http://127.0.0.1:5000/healthz
# → "ok"
# Verify audit role is wired
podman exec -it mimic-backend python -c 'from mimic.config import get_settings; \
print(get_settings().database_audit_url is not None)'
# → True
```
If any of these fail, do **not** announce the release. Investigate via
`journalctl --user -u mimic-backend.service -e`.
## Upgrade procedure
Steady-state release flow:
1. CI builds `mimic-backend:X.Y.Z` and `mimic-frontend:X.Y.Z` and pushes
them to `registry.try2get.in`. The tag policy is the same as the
sprint 0 follow-up F-D1.
2. Update the Quadlet `.container` files on the deploy host to point at
the new tags (single line each).
3. `systemctl --user daemon-reload`.
4. `systemctl --user restart mimic-backend.service mimic-frontend.service`.
Quadlet pulls the new image automatically.
5. Run smoke validation. Tail logs for one minute.
If the release ships schema changes, Alembic runs `upgrade head` on
container start — the migration is the **first** thing the entrypoint
does. A failed migration prevents the new container from accepting
traffic and leaves the previous container's exit code visible in
`journalctl`.
## Rollback procedure
A rollback covers both image and schema. The schema rollback is
optional and only required when the new release includes a non-additive
migration.
```bash
# Image-level rollback only (additive schema, no data shape change)
sed -i 's|Image=.*mimic-backend:.*|Image=registry.try2get.in/mimic-backend:<previous>|' \
~/.config/containers/systemd/mimic-backend.container
systemctl --user daemon-reload
systemctl --user restart mimic-backend.service
# Schema-affecting rollback
podman exec -it mimic-backend alembic downgrade <previous-revision>
# then image rollback as above
```
Always confirm the target Alembic revision matches the previous image's
shipped revision before downgrading — there is no enforcement and a
mismatch is recoverable but unpleasant.
## Open items captured in `tasks/todo.md`
- `FERNET-KEY` (CI follow-ups) — provision `FERNET_KEY_TEST` Gitea secret
for CI so integration tests can exercise the encrypted-credential path.
- `F-D1` (Frontend follow-ups) — pin every production image by minor +
digest. This document already mandates the policy; F-D1 is the
implementation step.
- `F-D2` (Frontend follow-ups) — decide whether Caddy or the in-image
`HEALTHCHECK` owns liveness probing. Currently neither is wired.
- `F-D3` — security response headers ownership (Caddy vs nginx.conf).

262
docs/podman-runner-setup.md Normal file
View File

@@ -0,0 +1,262 @@
# Gitea Actions runner — Podman rootless runbook
Archived setup procedure for the `gitea-runner` host that drives Mimic CI
(`.gitea/workflows/ci.yml`). Captures the corrections that emerged during
sprint 0 install so future operators don't re-discover the same traps.
## Target architecture
- **Host** : same VM as the Gitea server (sprint 0 deployment choice).
- **Container runtime** : Podman rootless under the existing `gitea` system
user. No new account, no rootful daemon.
- **Runner image** : `docker.io/gitea/act_runner:X.Y.Z` (pinned, see [Pin
policy](#pin-policy)).
- **Auto-start** : Quadlet (`~/.config/containers/systemd/<name>.container`)
— the upstream-recommended pattern since Podman 4.4. `podman generate
systemd` is officially deprecated; do not introduce it.
- **Label exposed to workflows** : `linux` (single, kept short, matches the
`runs-on: linux` line in `.gitea/workflows/ci.yml`).
## Prerequisites on the host
| Component | Requirement | Verify |
| --- | --- | --- |
| Podman | ≥ 4.4 (Quadlet support) | `podman --version` |
| Rootless mode | enabled | `podman info --format '{{.Host.Security.Rootless}}'``true` |
| systemd user mode | linger on for the runner user | `loginctl show-user <user> \| grep Linger` |
| `podman.socket` user unit | available | `ls /usr/lib/systemd/user/podman.socket` |
| Gitea Actions | enabled in `app.ini` | `[actions] ENABLED = true` then restart |
If Gitea Actions was never activated, edit `/etc/gitea/app.ini`:
```ini
[actions]
ENABLED = true
[actions.log_compression]
ENABLED = true
```
Restart with `sudo systemctl restart gitea`. The UI exposes
`Site Administration → Actions → Runners` once enabled.
## Pin policy
**Never use `:latest` for the runner image in production.** Pin a concrete
`gitea/act_runner:X.Y.Z` tag and bump explicitly through this runbook. The
same policy is tracked for every other production image in
[`tasks/todo.md`](../tasks/todo.md) follow-up **F-D1** (digest pinning
roadmap).
To find the current release: <https://gitea.com/gitea/act_runner/releases>.
## Step 1 — Switch to the runner user
```bash
sudo machinectl shell <user>@ # or: sudo -iu <user>
id # capture $UID for later substitution
podman info --format '{{.Host.Security.Rootless}}' # must print "true"
```
If `loginctl show-user <user> | grep Linger` reports `Linger=no`, run as
root **before** going further:
```bash
sudo loginctl enable-linger <user>
```
Without linger the Podman user-mode socket dies when `<user>` logs out and
the runner stops accepting jobs.
## Step 2 — Activate the Podman socket
```bash
systemctl --user enable --now podman.socket
systemctl --user status podman.socket
ls -la /run/user/$(id -u)/podman/podman.sock # exists, mode 0660
```
## Step 3 — Pull the runner image
```bash
podman pull docker.io/gitea/act_runner:X.Y.Z # replace X.Y.Z
```
## Step 4 — Generate a baseline config
```bash
mkdir -p ~/.config/act_runner ~/.local/share/act_runner
cd ~/.config/act_runner
podman run --rm docker.io/gitea/act_runner:X.Y.Z \
act_runner generate-config > config.yaml
```
Edit `~/.config/act_runner/config.yaml` — only these keys matter:
```yaml
runner:
capacity: 2
envs:
DOCKER_HOST: "unix:///var/run/docker.sock" # path as seen by the container
labels:
- "linux:docker://node:22-alpine"
container:
network: "bridge"
privileged: false
docker_host: "unix:///var/run/docker.sock"
options: "--security-opt label=disable" # see SELinux note below
```
## Step 5 — Register the runner (single-use token)
> **Gotcha — register pings the container daemon.**
> Even though `act_runner register` writes no actual job, it sanity-checks
> at startup that it can reach a container runtime. Without the socket
> mounted, register fails with `cannot ping container daemon` and the
> credential file `~/.local/share/act_runner/.runner` is never written.
> Mount the socket on the register one-shot too — not only on the daemon.
Generate a registration token in `Site Administration → Actions → Runners
→ Create new Runner`, then run **once**:
```bash
podman run --rm \
--security-opt label=disable \
-v ~/.config/act_runner/config.yaml:/config.yaml \
-v ~/.local/share/act_runner:/data \
-v /run/user/$(id -u)/podman/podman.sock:/var/run/docker.sock \
-w /data \
-e GITEA_INSTANCE_URL=https://repo.try2get.in \
-e GITEA_RUNNER_REGISTRATION_TOKEN=<TOKEN_FROM_GITEA_UI> \
-e GITEA_RUNNER_NAME=gitea-runner \
-e GITEA_RUNNER_LABELS=linux \
docker.io/gitea/act_runner:X.Y.Z \
act_runner register --no-interactive
```
The token is **single-use**: invalidated the moment `register` succeeds.
Generate a fresh one for each re-registration.
> **Secret handling convention.**
> Do not paste the registration token into chat, agent transcripts, or
> issue trackers. Drop it on disk (e.g. `~/runner-token.txt`), `cat` it
> into the environment for the one-shot above, then `shred -u` the file.
> This mirrors the team-lead "secrets via file, not chat" rule.
Verify the runner appears in the Gitea UI at
`https://repo.try2get.in/-/admin/actions/runners` with status `idle`.
## Step 6 — Quadlet unit (auto-start)
`~/.config/containers/systemd/gitea-runner.container` :
```ini
[Unit]
Description=Gitea Actions Runner (Mimic) — Podman rootless
After=podman.socket
Requires=podman.socket
[Container]
Image=docker.io/gitea/act_runner:X.Y.Z
ContainerName=gitea-runner
SecurityLabelDisable=true
Volume=%h/.config/act_runner/config.yaml:/config.yaml,ro
Volume=%h/.local/share/act_runner:/data
Volume=/run/user/%U/podman/podman.sock:/var/run/docker.sock
WorkingDir=/data
Exec=act_runner daemon --config /config.yaml
[Service]
Restart=on-failure
RestartSec=5
TimeoutStartSec=900
[Install]
WantedBy=default.target
```
Notes:
- `%h` expands to the runner user's `$HOME`, `%U` to the runner user's UID.
Hardcoding `1000` (or any specific UID) was a sprint 0 mistake — the
actual `gitea` UID on this host is **1005**. Quadlet substitution makes
the unit portable across hosts.
- `SecurityLabelDisable=true` is the Quadlet equivalent of
`--security-opt label=disable`. It bypasses SELinux container labelling
so the rootless container can `read+write` the host Podman socket. On
SELinux-disabled systems (Debian/Ubuntu vanilla) this is a no-op; on
RHEL/Fedora-like it is required — without it nginx-style "Permission
denied" appears on socket connect.
Activate:
```bash
systemctl --user daemon-reload
systemctl --user start gitea-runner.service # generated from .container
systemctl --user enable gitea-runner.service # persist across reboots
journalctl --user -u gitea-runner.service -e
```
Quadlet generates `gitea-runner.service` automatically; do not create it by
hand under `~/.config/systemd/user/`.
## Step 7 — Smoke validation
Push a transient workflow on a feature branch. Example used during sprint 0
(file lived at `.gitea/workflows/smoke.yml` on `chore/podman-and-ci`,
removed after green):
```yaml
name: smoke
on:
push:
branches: [chore/podman-and-ci]
workflow_dispatch:
jobs:
hello:
runs-on: linux
steps:
- run: |
echo "host: $(uname -a)"
id
head -3 /etc/os-release
```
Job picked up and green within ~10 s on the Gitea Actions tab → runner is
operational. Failures usually trace back to one of the gotchas captured
above (`journalctl --user -u gitea-runner.service -e` is authoritative).
## Step 8 — Repo secrets
CI consumes the following secrets, configured per repo at
`<repo>/settings/actions/secrets`:
| Secret | Use | Value |
| --- | --- | --- |
| `FERNET_KEY_TEST` | `MIMIC_FERNET_KEY` in CI jobs | `python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"` once, fixed thereafter |
Never reuse production Fernet material in CI.
## Decommissioning
```bash
# As the runner user:
systemctl --user disable --now gitea-runner.service
rm ~/.config/containers/systemd/gitea-runner.container
systemctl --user daemon-reload
rm -rf ~/.config/act_runner ~/.local/share/act_runner
# Drop the runner entry in Gitea UI: Site Admin → Actions → Runners → Delete.
```
The Podman socket and linger setting stay — they are user-level and shared
with anything else the user runs.
## Cross-references
- Sprint 0 decisions: [`tasks/spec-decisions.md`](../tasks/spec-decisions.md)
(D-007 reverse proxy scope, D-010 Ansible playbook scope).
- CI workflow: [`.gitea/workflows/ci.yml`](../.gitea/workflows/ci.yml).
- Deferred CI work: [`tasks/todo.md`](../tasks/todo.md) section "CI
follow-ups (sprint 1+)".

22
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,22 @@
node_modules
dist
.vite
coverage
playwright-report
test-results
.eslintcache
# Editor / OS
.DS_Store
.idea
.vscode
# Env (never bake into image)
.env
.env.*
!.env.example
# Git internals
.git
.gitignore
.gitattributes

37
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
# syntax=docker/dockerfile:1.7
# --- Stage 1: build --------------------------------------------------------
FROM node:22-alpine AS build
ENV CI=true \
npm_config_audit=false \
npm_config_fund=false
WORKDIR /app
# Reproducible install: lockfile only, no scripts at install time.
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# App sources.
COPY . .
# Production build → /app/dist
RUN npm run build
# --- Stage 2: runtime ------------------------------------------------------
# nginxinc/nginx-unprivileged is the upstream-maintained variant that runs
# nginx as a non-root user out of the box (no chown gymnastics, /var/cache
# /var/run/nginx are owned by uid 101). It already listens on 8080.
FROM docker.io/nginxinc/nginx-unprivileged:alpine
# Minimal SPA serving config: static dist + try_files SPA fallback.
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Static assets owned by the nginx user (uid 101 in this image).
COPY --from=build --chown=101:101 /app/dist /usr/share/nginx/html
EXPOSE 8080
# Foreground nginx so the container lifecycle matches.
CMD ["nginx", "-g", "daemon off;"]

30
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,30 @@
server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
index index.html;
# SPA routing: every unknown path falls back to index.html.
location / {
try_files $uri $uri/ /index.html;
}
# Long-cache hashed asset bundles emitted by Vite under /assets/.
location /assets/ {
access_log off;
expires 1y;
add_header Cache-Control "public, immutable";
}
# Health endpoint scraped by Caddy / Prometheus blackbox.
location = /healthz {
access_log off;
default_type text/plain;
return 200 "ok\n";
}
# No directory listing, no server tokens.
autoindex off;
server_tokens off;
}

View File

@@ -11,20 +11,38 @@ import { useClock } from './useClock';
* ┌──────────────────────────────────────────────────────────────┐
* │ StatusRail (link health · active run · UTC clock · build) │
* ├──────────┬───────────────────────────────────────────────────┤
* │ │ │
* │ Sidebar │ Outlet (current screen) │
* │ │ │
* └──────────┴───────────────────────────────────────────────────┘
*
* Unauthenticated visitors are redirected to /login. Inside the shell, the
* sidebar and rail expose only what the current session's role can see —
* this is layout, not enforcement: the API remains the source of truth on
* permissions (D-008 / F11).
* Session resolution flow:
* 1. useSession queries /api/v1/auth/me with the cookie that travels
* automatically on every fetch.
* 2. While the query is in flight, the shell renders a minimal masthead
* with a "resolving session" pill — avoids a /login flash for users
* who land on a protected URL with a valid cookie.
* 3. Resolved null → redirect to /login. Resolved User → render the
* shell. Errors fall through to the same null path (treat hard
* backend errors as unauthenticated for routing purposes, the
* LoginPage surfaces the underlying message).
*
* Backend RBAC remains authoritative — the role enum here only drives
* layout (which nav items appear).
*/
export function AppShell() {
const { user } = useSession();
const { user, isLoading } = useSession();
const clock = useClock();
if (isLoading) {
return (
<div className="h-screen flex flex-col" style={{ backgroundColor: 'var(--surface-0)' }}>
<StatusRail clock={clock} sessionState="resolving" />
<div className="flex-1 flex items-center justify-center label-system text-fg-faint">
resolving session
</div>
</div>
);
}
if (!user) {
return <Navigate to="/login" replace />;
}

View File

@@ -1,7 +1,8 @@
import { NavLink } from 'react-router-dom';
import { NavLink, useNavigate } from 'react-router-dom';
import { useQueryClient } from '@tanstack/react-query';
import { clsx } from 'clsx';
import { Logo } from '@/components/brand/Logo';
import { useSession } from '@/session/useSession';
import { useSession, SESSION_QUERY_KEY } from '@/session/useSession';
import { ROLE_LABELS, isRT, isLead } from '@/types/roles';
interface NavItem {
@@ -21,22 +22,36 @@ const NAV_ITEMS: NavItem[] = [
];
export function Sidebar() {
const { user, signOut } = useSession();
const { user, signOut, isSigningOut } = useSession();
const navigate = useNavigate();
const queryClient = useQueryClient();
if (!user) return null;
const canRT = isRT(user.role);
const canLead = isLead(user.role);
const visible = NAV_ITEMS.filter((item) => item.show({ canRT, canLead }));
// Logout is a one-shot side effect that always lands on /login regardless
// of whether the backend call succeeded — a failed logout still expires
// the local session cache so the user is not stuck on a broken cookie.
const handleSignOut = () => {
void (async () => {
try {
await signOut();
} catch {
queryClient.setQueryData(SESSION_QUERY_KEY, null);
}
void navigate('/login', { replace: true });
})();
};
return (
<aside
className="flex flex-col h-full w-56 border-r"
style={{ borderColor: 'var(--line-default)', backgroundColor: 'var(--surface-1)' }}
>
<div
className="px-3 py-4 border-b"
style={{ borderColor: 'var(--line-default)' }}
>
<div className="px-3 py-4 border-b" style={{ borderColor: 'var(--line-default)' }}>
<Logo build="0.1.0" />
</div>
@@ -93,7 +108,7 @@ export function Sidebar() {
<div className="flex items-center justify-between">
<div className="min-w-0">
<div className="truncate text-fg-default" style={{ fontSize: '12px' }}>
{user.displayName}
{user.display_name ?? user.username}
</div>
<div
className="label-system mt-0.5"
@@ -104,15 +119,16 @@ export function Sidebar() {
</div>
<button
type="button"
onClick={signOut}
className="label-system text-fg-faint hover:text-fg-default px-2 py-1"
onClick={handleSignOut}
disabled={isSigningOut}
className="label-system text-fg-faint hover:text-fg-default px-2 py-1 disabled:opacity-50"
style={{ border: '1px solid var(--line-default)', borderRadius: 'var(--radius-sm)' }}
>
Sign out
{isSigningOut ? '…' : 'Sign out'}
</button>
</div>
<div className="font-mono tabular text-fg-faint truncate" style={{ fontSize: '10px' }}>
ENG · {user.engagementName}
{user.username}
</div>
</div>
</aside>

View File

@@ -18,6 +18,8 @@ interface StatusRailProps {
activeRunId?: string;
activeRunState?: 'running' | 'paused' | 'aborting' | 'idle';
clock: string;
/** Optional session resolution indicator, shown while /auth/me is in flight. */
sessionState?: 'resolving';
}
const LINK_LABEL: Record<LinkState, string> = {
@@ -37,6 +39,7 @@ export function StatusRail({
activeRunId,
activeRunState = 'idle',
clock,
sessionState,
}: StatusRailProps) {
return (
<div
@@ -76,6 +79,13 @@ export function StatusRail({
<span className="flex-1" />
{sessionState === 'resolving' && (
<span className="inline-flex items-center gap-2">
<span className="status-dot text-fg-faint pulsing" />
<span className="label-system tabular text-fg-faint">SESSION · RESOLVING</span>
</span>
)}
<span className="font-mono tabular text-fg-muted" style={{ fontSize: '11px' }}>
{clock}
</span>

97
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,97 @@
/**
* Thin fetch wrapper for the Mimic backend.
*
* Responsibilities:
* - Inject `credentials: 'include'` so the session cookie travels with
* every call (cookie is HttpOnly + Secure, set by the backend).
* - Send JSON on bodied requests and parse JSON responses.
* - Normalize 4xx/5xx into a typed `ApiClientError` so callers can
* branch on `error.status` and `error.body` without re-parsing.
*
* Deliberate non-features:
* - No retry loop. TanStack Query owns that policy.
* - No CSRF token: same-origin in prod (Caddy), same-origin via the
* Vite proxy in dev. SameSite=Lax cookie is enough — no cross-site
* form posts in scope.
*/
import type { ApiError } from '@/types/api';
const DEFAULT_BASE = '/api/v1';
export class ApiClientError extends Error {
status: number;
body: ApiError | null;
constructor(status: number, message: string, body: ApiError | null) {
super(message);
this.name = 'ApiClientError';
this.status = status;
this.body = body;
}
}
interface ApiRequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
body?: unknown;
signal?: AbortSignal;
}
async function parseBody(response: Response): Promise<unknown> {
if (response.status === 204) return null;
const text = await response.text();
if (!text) return null;
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
/**
* Try to coerce the response body into the `{error, message, details?}`
* envelope documented in api.md. Backends that haven't routed every error
* through that handler yet (raw `flask.abort(...)` HTML output, plain text,
* other shapes) return `null` so callers can fall back to a generic message.
*/
function bodyAsApiError(body: unknown): ApiError | null {
if (typeof body !== 'object' || body === null) return null;
const obj = body as Record<string, unknown>;
if (typeof obj.error === 'string' && typeof obj.message === 'string') {
return obj as unknown as ApiError;
}
return null;
}
export async function apiFetch<T>(path: string, opts: ApiRequestOptions = {}): Promise<T> {
const url = path.startsWith('http') ? path : `${DEFAULT_BASE}${path}`;
const method = opts.method ?? 'GET';
const headers: Record<string, string> = {
Accept: 'application/json',
};
let body: BodyInit | undefined;
if (opts.body !== undefined) {
headers['Content-Type'] = 'application/json';
body = JSON.stringify(opts.body);
}
const response = await fetch(url, {
method,
headers,
body,
credentials: 'include',
signal: opts.signal,
});
const parsed = await parseBody(response);
if (!response.ok) {
throw new ApiClientError(
response.status,
`${method} ${url}${response.status.toString()}`,
bodyAsApiError(parsed),
);
}
return parsed as T;
}

View File

@@ -1,60 +0,0 @@
import type { SessionUser } from '@/types/roles';
/**
* Sprint 0 mock — no backend yet. The session is selected from /login
* and persisted in sessionStorage so route navigations preserve role.
* Real auth lands later (D-003: local user/password v1, Keycloak OIDC v2).
*/
const STORAGE_KEY = 'mimic.mock.session';
export const MOCK_SESSIONS: Record<string, SessionUser> = {
rt_operator: {
id: 'usr_001',
displayName: 'M. Dubreuil',
role: 'rt_operator',
engagementId: 'eng_42',
engagementName: 'Démo Client X',
},
rt_lead: {
id: 'usr_002',
displayName: 'A. Verlhac',
role: 'rt_lead',
engagementId: 'eng_42',
engagementName: 'Démo Client X',
},
soc_analyst: {
id: 'usr_soc_07',
displayName: 'SOC · session #07',
role: 'soc_analyst',
engagementId: 'eng_42',
engagementName: 'Démo Client X',
},
};
export function readMockSession(): SessionUser | null {
try {
const raw = sessionStorage.getItem(STORAGE_KEY);
if (!raw) return null;
const parsed: unknown = JSON.parse(raw);
if (
typeof parsed === 'object' &&
parsed !== null &&
'role' in parsed &&
typeof (parsed as SessionUser).role === 'string'
) {
return parsed as SessionUser;
}
return null;
} catch {
return null;
}
}
export function writeMockSession(user: SessionUser): void {
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(user));
}
export function clearMockSession(): void {
sessionStorage.removeItem(STORAGE_KEY);
}

View File

@@ -1,5 +1,4 @@
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { Root } from '@/routing/Root';
import { AppShell } from '@/components/shell/AppShell';
import { LoginPage } from '@/screens/login/LoginPage';
import { EngagementsPage } from '@/screens/engagements/EngagementsPage';
@@ -10,35 +9,31 @@ import { TtpLibraryPage } from '@/screens/library/TtpLibraryPage';
import { AuditPage } from '@/screens/audit/AuditPage';
/**
* Routes mirror spec §9 (UI Web) with sprint 0 placeholders.
* Routes mirror spec §9 (UI Web).
*
* The Root route mounts SessionProvider once for the entire tree. All
* top-level paths (login, app shell, fallback) are children of that
* single Root so they share one session state — no provider forking
* between routes.
* Session state lives in TanStack Query (key `SESSION_QUERY_KEY`), mounted
* once in App.tsx via QueryClientProvider — no per-route provider needed.
*
* AppShell is the gate for authenticated routes: it reads useSession() and
* redirects to /login on null user. The login route lives outside the
* shell so it stays reachable when unauthenticated.
*
* Once real engagement scoping lands, sub-routes nest under
* /engagements/:eid (spec §9). Sprint 0 keeps URLs flat so the
* wireframes are reachable directly.
* /engagements/:eid (spec §9). Sprint 1 keeps URLs flat.
*/
export const router = createBrowserRouter([
{
element: <Root />,
children: [
{ index: true, element: <Navigate to="/login" replace /> },
{ path: 'login', element: <LoginPage /> },
{ index: true, element: <Navigate to="/engagements" replace /> },
{ path: '/login', element: <LoginPage /> },
{
element: <AppShell />,
children: [
{ path: 'engagements', element: <EngagementsPage /> },
{ path: 'library', element: <TtpLibraryPage /> },
{ path: 'scenarios', element: <ScenarioComposerPage /> },
{ path: 'runs', element: <LiveCockpitPage /> },
{ path: 'reports', element: <ReportPage /> },
{ path: 'audit', element: <AuditPage /> },
],
},
{ path: '*', element: <Navigate to="/login" replace /> },
{ path: '/engagements', element: <EngagementsPage /> },
{ path: '/library', element: <TtpLibraryPage /> },
{ path: '/scenarios', element: <ScenarioComposerPage /> },
{ path: '/runs', element: <LiveCockpitPage /> },
{ path: '/reports', element: <ReportPage /> },
{ path: '/audit', element: <AuditPage /> },
],
},
{ path: '*', element: <Navigate to="/engagements" replace /> },
]);

View File

@@ -1,16 +0,0 @@
import { Outlet } from 'react-router-dom';
import { SessionProvider } from '@/session/SessionContext';
/**
* Root route element. Mounts SessionProvider once for the entire app so
* every nested route — login, app shell, fallback — shares one session
* state. Kept in its own file so router.tsx exports only the router
* config (fast-refresh friendly).
*/
export function Root() {
return (
<SessionProvider>
<Outlet />
</SessionProvider>
);
}

View File

@@ -0,0 +1,93 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { EngagementCreateDialog } from './EngagementCreateDialog';
import { installFetchMock, renderWithProviders } from '@/test/testUtils';
describe('EngagementCreateDialog', () => {
let fetchMock: ReturnType<typeof installFetchMock>;
afterEach(() => {
fetchMock?.restore();
});
it('rejects empty client name client-side without calling the backend', () => {
fetchMock = installFetchMock([]);
renderWithProviders(<EngagementCreateDialog onClose={vi.fn()} />);
fireEvent.click(screen.getByRole('button', { name: /arm engagement/i }));
expect(screen.getByText(/client requis/i)).toBeInTheDocument();
expect(fetchMock.calls).toHaveLength(0);
});
it('maps 422 backend errors (details[].loc) to per-field messages', async () => {
fetchMock = installFetchMock([
{
status: 422,
body: {
error: 'validation_error',
message: 'request failed',
details: [
{
loc: ['client_name'],
msg: 'String should have at least 1 character',
type: 'string_too_short',
},
],
},
},
]);
renderWithProviders(<EngagementCreateDialog onClose={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/client name/i), { target: { value: 'x' } });
fireEvent.click(screen.getByRole('button', { name: /arm engagement/i }));
await waitFor(() => {
expect(screen.getByText(/at least 1 character/i)).toBeInTheDocument();
});
});
it('invalidates the engagements query and closes on success', async () => {
const onClose = vi.fn();
fetchMock = installFetchMock([
{
status: 201,
body: {
id: 'eng_new',
client_name: 'OPERATION ZETA',
description: null,
status: 'draft',
c2_type: 'mythic',
start_date: null,
end_date: null,
},
},
]);
renderWithProviders(<EngagementCreateDialog onClose={onClose} />);
fireEvent.change(screen.getByLabelText(/client name/i), {
target: { value: 'OPERATION ZETA' },
});
fireEvent.click(screen.getByRole('button', { name: /arm engagement/i }));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(fetchMock.calls[0]?.url).toBe('/api/v1/engagements/');
expect(fetchMock.calls[0]?.init?.method).toBe('POST');
});
it('surfaces a generic top-of-form error on 401', async () => {
fetchMock = installFetchMock([
{
status: 401,
body: { error: 'not_authenticated', message: 'no active session' },
},
]);
renderWithProviders(<EngagementCreateDialog onClose={vi.fn()} />);
fireEvent.change(screen.getByLabelText(/client name/i), {
target: { value: 'Anyone' },
});
fireEvent.click(screen.getByRole('button', { name: /arm engagement/i }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toMatch(/session expirée/i);
});
});

View File

@@ -0,0 +1,517 @@
import {
useEffect,
useId,
useMemo,
useRef,
useState,
type FormEvent,
type KeyboardEvent,
type ReactNode,
type Ref,
} from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Button } from '@/components/ui/Button';
import { ApiClientError } from '@/lib/api';
import type { ApiError, C2Type, PydanticErrorItem } from '@/types/api';
import { createEngagement, ENGAGEMENTS_QUERY_KEY } from './engagementsApi';
interface EngagementCreateDialogProps {
onClose: () => void;
}
type FieldKey = 'client_name' | 'description' | 'c2_type';
type FieldErrors = Partial<Record<FieldKey, string>>;
const C2_OPTIONS: ReadonlyArray<{ value: C2Type; label: string }> = [
{ value: 'mythic', label: 'Mythic' },
{ value: 'home', label: 'Home (RT-internal)' },
];
/**
* "Arm engagement" dialog.
*
* Visual grammar:
* - Backdrop: graphite dim + faint scanline texture, no blur. Reads as
* "the cockpit is paused while you issue a command", not a sleek
* SaaS overlay.
* - Surface: --surface-3 (one level above panels), corner-mark utility
* at the four corners, hairline divider beneath the masthead.
* - Inputs: label-system uppercase + underline that lights amber on
* focus. No rounded boxes; the form should read as a console.
* - Submit: primary amber Button — the same accent used for RT-only
* actions throughout the app, so the action lineage is obvious.
*
* Contract (api.md):
* POST /api/v1/engagements/ with { client_name (required), description?,
* c2_type? (default mythic), start_date?, end_date? }. Backend returns
* the created Engagement on 201, the uniform { error, message, details? }
* envelope on 422 / 4xx. Per-field details on 422 are matched via the
* last segment of `loc`.
*/
export function EngagementCreateDialog({ onClose }: EngagementCreateDialogProps) {
const titleId = useId();
const surfaceRef = useRef<HTMLDivElement>(null);
const firstFieldRef = useRef<HTMLInputElement>(null);
const [clientName, setClientName] = useState('');
const [description, setDescription] = useState('');
const [c2Type, setC2Type] = useState<C2Type>('mythic');
const [fieldErrors, setFieldErrors] = useState<FieldErrors>({});
const [topError, setTopError] = useState<string | null>(null);
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: createEngagement,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ENGAGEMENTS_QUERY_KEY });
onClose();
},
onError: (err) => {
if (err instanceof ApiClientError) {
if (err.status === 422 && err.body?.details) {
setFieldErrors(mapValidationErrors(err.body.details));
setTopError(null);
return;
}
if (err.status === 401) {
setTopError('Session expirée. Reconnectez-vous.');
return;
}
if (err.status === 403) {
setTopError('Action interdite pour ce rôle.');
return;
}
if (err.body?.message) {
setTopError(genericMessage(err.body));
return;
}
}
setTopError('Création impossible. Réessayez dans un instant.');
},
});
const isPending = mutation.isPending;
useEffect(() => {
firstFieldRef.current?.focus();
const onKeyDown = (e: globalThis.KeyboardEvent) => {
if (e.key === 'Escape' && !isPending) {
e.preventDefault();
onClose();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [isPending, onClose]);
const handleSurfaceKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
if (e.key !== 'Tab' || !surfaceRef.current) return;
const focusables = surfaceRef.current.querySelectorAll<HTMLElement>(
'input, textarea, select, button, [tabindex]:not([tabindex="-1"])',
);
if (focusables.length === 0) return;
const first = focusables[0];
const last = focusables[focusables.length - 1];
if (!first || !last) return;
const active = document.activeElement;
if (e.shiftKey && active === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && active === last) {
e.preventDefault();
first.focus();
}
};
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
setFieldErrors({});
setTopError(null);
if (!clientName.trim()) {
setFieldErrors({ client_name: 'Client requis.' });
return;
}
mutation.mutate({
client_name: clientName.trim(),
description: description.trim() || null,
c2_type: c2Type,
});
};
const draftId = useMemo(() => generateDraftId(), []);
return (
<div
role="presentation"
onMouseDown={(e) => {
if (e.target === e.currentTarget && !isPending) onClose();
}}
style={{
position: 'fixed',
inset: 0,
zIndex: 50,
background:
'radial-gradient(circle at 50% 35%, oklch(7.4% 0.012 247 / 0.55), oklch(5.8% 0.012 247 / 0.82) 60%)',
backgroundColor: 'oklch(5.8% 0.012 247 / 0.78)',
}}
>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'repeating-linear-gradient(0deg, transparent 0, transparent 2px, oklch(100% 0 0 / 0.012) 2px, oklch(100% 0 0 / 0.012) 3px)',
pointerEvents: 'none',
}}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
ref={surfaceRef}
onKeyDown={handleSurfaceKeyDown}
className="corner-mark"
style={{
position: 'absolute',
top: '14%',
left: '50%',
transform: 'translateX(-50%)',
width: 'min(520px, calc(100vw - 32px))',
backgroundColor: 'var(--surface-3)',
border: '1px solid var(--line-strong)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-pop)',
animation: 'dialog-in 140ms var(--ease-mech) both',
}}
>
<div
className="flex items-center justify-between gap-3 px-5 py-3 border-b"
style={{ borderColor: 'var(--line-default)' }}
>
<div className="flex items-center gap-3">
<span
aria-hidden="true"
className="status-dot pulsing"
style={{ color: 'var(--accent-rt)' }}
/>
<h2
id={titleId}
className="label-system"
style={{ color: 'var(--accent-rt)', letterSpacing: '0.18em' }}
>
ARM · NEW ENGAGEMENT
</h2>
</div>
<span
className="font-mono tabular text-fg-faint"
style={{ fontSize: '10.5px' }}
>
{draftId}
</span>
</div>
<div
aria-hidden="true"
style={{
height: 1,
background:
'linear-gradient(90deg, transparent 0%, var(--accent-rt) 50%, transparent 100%)',
opacity: 0.55,
}}
/>
<form onSubmit={handleSubmit} className="px-5 py-4 space-y-4" noValidate>
{topError && (
<div
role="alert"
className="label-system"
style={{
color: 'var(--state-failed)',
border: '1px solid var(--state-failed)',
padding: '6px 10px',
borderRadius: 'var(--radius-sm)',
}}
>
{topError}
</div>
)}
<ConsoleField
label="Client name"
required
value={clientName}
onChange={setClientName}
error={fieldErrors.client_name}
disabled={isPending}
placeholder="Démo Client X"
ref={firstFieldRef}
/>
<ConsoleSelect
label="C2 backend"
value={c2Type}
onChange={(v) => setC2Type(v)}
error={fieldErrors.c2_type}
disabled={isPending}
options={C2_OPTIONS}
/>
<ConsoleTextarea
label="Brief"
value={description}
onChange={setDescription}
error={fieldErrors.description}
disabled={isPending}
placeholder="Scope notes, ROE pointers, post-mission expectations."
/>
<div
className="flex items-center justify-between gap-3 pt-3 border-t"
style={{ borderColor: 'var(--line-default)' }}
>
<span className="label-system text-fg-faint">
{isPending ? '// transmitting …' : '// awaiting confirmation'}
</span>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onClose}
disabled={isPending}
>
Cancel
</Button>
<Button type="submit" variant="primary" size="sm" disabled={isPending}>
{isPending ? 'Arming …' : 'Arm engagement →'}
</Button>
</div>
</div>
</form>
</div>
<style>{`
@keyframes dialog-in {
0% { opacity: 0; transform: translate(-50%, calc(-50% + 4px)) translateY(8px); }
100% { opacity: 1; transform: translateX(-50%) translateY(0); }
}
`}</style>
</div>
);
}
function mapValidationErrors(details: PydanticErrorItem[]): FieldErrors {
const out: FieldErrors = {};
for (const item of details) {
const last = item.loc[item.loc.length - 1];
if (last === 'client_name' || last === 'description' || last === 'c2_type') {
out[last] = item.msg;
}
}
return out;
}
function genericMessage(body: ApiError): string {
// Capitalize first letter for display while keeping the message verbatim.
const msg = body.message.trim();
if (!msg) return 'Création impossible.';
return msg.charAt(0).toUpperCase() + msg.slice(1);
}
function generateDraftId(): string {
const t = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
return `DRAFT-${t.getUTCFullYear().toString().slice(-2)}${pad(t.getUTCMonth() + 1)}${pad(t.getUTCDate())}-${pad(t.getUTCHours())}${pad(t.getUTCMinutes())}`;
}
interface ConsoleFieldProps {
label: string;
value: string;
onChange: (next: string) => void;
required?: boolean;
disabled?: boolean;
placeholder?: string;
error?: string;
mono?: boolean;
}
function ConsoleField({
label,
value,
onChange,
required,
disabled,
placeholder,
error,
mono,
ref,
}: ConsoleFieldProps & { ref?: Ref<HTMLInputElement> }): ReactNode {
const id = useId();
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block label-system flex items-center justify-between">
<span>{label}</span>
{required && (
<span style={{ color: 'var(--accent-rt)' }} aria-hidden="true">
· required
</span>
)}
</label>
<input
id={id}
ref={ref}
type="text"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={placeholder}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${id}-err` : undefined}
className={mono ? 'font-mono tabular' : 'font-sans'}
style={{
width: '100%',
height: 30,
padding: '0 0 4px 0',
backgroundColor: 'transparent',
color: 'var(--fg-default)',
border: 'none',
borderBottom: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-strong)'}`,
borderRadius: 0,
fontSize: 13,
outline: 'none',
}}
onFocus={(e) => {
if (!error) {
e.currentTarget.style.borderBottomColor = 'var(--accent-rt)';
}
}}
onBlur={(e) => {
if (!error) {
e.currentTarget.style.borderBottomColor = 'var(--line-strong)';
}
}}
/>
{error && (
<p id={`${id}-err`} className="label-system" style={{ color: 'var(--state-failed)' }}>
{error}
</p>
)}
</div>
);
}
interface ConsoleSelectProps<T extends string> {
label: string;
value: T;
onChange: (next: T) => void;
options: ReadonlyArray<{ value: T; label: string }>;
disabled?: boolean;
error?: string;
}
function ConsoleSelect<T extends string>({
label,
value,
onChange,
options,
disabled,
error,
}: ConsoleSelectProps<T>): ReactNode {
const id = useId();
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block label-system">
{label}
</label>
<select
id={id}
value={value}
onChange={(e) => onChange(e.target.value as T)}
disabled={disabled}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${id}-err` : undefined}
className="font-sans"
style={{
width: '100%',
height: 30,
padding: '0 8px',
backgroundColor: 'var(--surface-inset)',
color: 'var(--fg-default)',
border: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-strong)'}`,
borderRadius: 'var(--radius-sm)',
fontSize: 12.5,
outline: 'none',
}}
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
{error && (
<p id={`${id}-err`} className="label-system" style={{ color: 'var(--state-failed)' }}>
{error}
</p>
)}
</div>
);
}
interface ConsoleTextareaProps {
label: string;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
placeholder?: string;
error?: string;
}
function ConsoleTextarea({
label,
value,
onChange,
disabled,
placeholder,
error,
}: ConsoleTextareaProps): ReactNode {
const id = useId();
return (
<div className="space-y-1.5">
<label htmlFor={id} className="block label-system">
{label}
</label>
<textarea
id={id}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
placeholder={placeholder}
rows={4}
aria-invalid={error ? 'true' : undefined}
aria-describedby={error ? `${id}-err` : undefined}
className="font-sans"
style={{
width: '100%',
padding: '8px 10px',
backgroundColor: 'var(--surface-inset)',
color: 'var(--fg-default)',
border: `1px solid ${error ? 'var(--state-failed)' : 'var(--line-default)'}`,
borderRadius: 'var(--radius-sm)',
fontSize: 12.5,
lineHeight: 1.55,
resize: 'vertical',
outline: 'none',
}}
/>
{error && (
<p id={`${id}-err`} className="label-system" style={{ color: 'var(--state-failed)' }}>
{error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, afterEach } from 'vitest';
import { screen, waitFor } from '@testing-library/react';
import { EngagementsPage } from './EngagementsPage';
import { installFetchMock, renderWithProviders } from '@/test/testUtils';
describe('EngagementsPage', () => {
let fetchMock: ReturnType<typeof installFetchMock>;
afterEach(() => {
fetchMock?.restore();
});
it('shows the loading row while the engagements query is pending', () => {
fetchMock = installFetchMock([]); // never resolve in this test
const pending: typeof fetch = () => new Promise(() => null);
globalThis.fetch = pending;
renderWithProviders(<EngagementsPage />);
expect(screen.getByText(/fetching engagements/i)).toBeInTheDocument();
});
it('renders the empty state when the list is empty', async () => {
fetchMock = installFetchMock([{ status: 200, body: [] }]);
renderWithProviders(<EngagementsPage />);
await screen.findByText(/no engagements yet/i);
});
it('renders the error state on 500', async () => {
fetchMock = installFetchMock([
{ status: 500, body: { error: 'internal_error', message: 'boom' } },
]);
renderWithProviders(<EngagementsPage />);
await waitFor(() => {
expect(screen.getByText(/fetch failed/i)).toBeInTheDocument();
});
expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument();
});
it('renders rows when the backend returns engagements', async () => {
fetchMock = installFetchMock([
{
status: 200,
body: [
{
id: 'eng_1',
client_name: 'Acme',
description: null,
status: 'active',
c2_type: 'mythic',
start_date: '2026-05-20',
end_date: '2026-05-30',
},
],
},
]);
renderWithProviders(<EngagementsPage />);
await screen.findByText('Acme');
expect(screen.getByText('MYTHIC')).toBeInTheDocument();
expect(screen.getByText(/active/)).toBeInTheDocument();
});
});

View File

@@ -1,25 +1,40 @@
import type { ReactNode } from 'react';
import { useState, type ReactNode } from 'react';
import { Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { Panel } from '@/components/ui/Panel';
import { Pill } from '@/components/ui/Pill';
import { Button } from '@/components/ui/Button';
import { MOCK_ENGAGEMENTS } from '@/mocks/fixtures';
import type { MockEngagement } from '@/mocks/fixtures';
import { ApiClientError } from '@/lib/api';
import type { Engagement, EngagementStatus } from '@/types/api';
import { ENGAGEMENTS_QUERY_KEY, fetchEngagements } from './engagementsApi';
import { EngagementCreateDialog } from './EngagementCreateDialog';
const STATUS_TONE: Record<MockEngagement['status'], 'running' | 'soc' | 'success' | 'pending'> = {
const STATUS_TONE: Record<EngagementStatus, 'running' | 'soc' | 'success' | 'pending'> = {
draft: 'pending',
active: 'running',
reporting: 'soc',
closed: 'soc',
archived: 'pending',
planning: 'success',
};
export function EngagementsPage() {
const [createOpen, setCreateOpen] = useState(false);
const query = useQuery<Engagement[]>({
queryKey: ENGAGEMENTS_QUERY_KEY,
queryFn: ({ signal }) => fetchEngagements(signal),
});
const engagements = query.data ?? [];
return (
<div className="px-8 py-6 space-y-6 max-w-[1400px] mx-auto">
<header className="flex items-end justify-between">
<div>
<div className="label-system mb-1">// Engagements</div>
<h1 className="font-display text-fg-default" style={{ fontSize: '22px', letterSpacing: '0.02em' }}>
<h1
className="font-display text-fg-default"
style={{ fontSize: '22px', letterSpacing: '0.02em' }}
>
Mission roster
</h1>
<p className="text-fg-muted mt-1" style={{ fontSize: '12.5px' }}>
@@ -27,47 +42,72 @@ export function EngagementsPage() {
runs, and reports.
</p>
</div>
<Button variant="primary">+ New engagement</Button>
<Button variant="primary" onClick={() => setCreateOpen(true)}>
+ New engagement
</Button>
</header>
<Panel
title="Active and recent"
meta={
<span className="tabular">
{MOCK_ENGAGEMENTS.length} entries · sorted by start date
{query.isLoading
? 'loading …'
: query.isError
? 'error'
: `${String(engagements.length)} entries`}
</span>
}
>
{query.isLoading ? (
<LoadingRow />
) : query.isError ? (
<ErrorRow error={query.error} onRetry={() => void query.refetch()} />
) : engagements.length === 0 ? (
<EmptyRow onCreate={() => setCreateOpen(true)} />
) : (
<EngagementsTable engagements={engagements} />
)}
</Panel>
{createOpen && <EngagementCreateDialog onClose={() => setCreateOpen(false)} />}
</div>
);
}
function EngagementsTable({ engagements }: { engagements: Engagement[] }) {
return (
<table className="w-full" style={{ fontSize: 12.5 }}>
<thead>
<tr className="text-fg-subtle">
<Th>Codename</Th>
<Th>Client</Th>
<Th>Status</Th>
<Th>C2</Th>
<Th align="right">Operators</Th>
<Th align="right">SOC</Th>
<Th>Window</Th>
<Th>Description</Th>
<Th />
</tr>
</thead>
<tbody>
{MOCK_ENGAGEMENTS.map((eng, idx) => (
{engagements.map((eng, idx) => (
<tr
key={eng.id}
style={{
borderTop: idx === 0 ? '1px solid var(--line-default)' : '1px solid var(--line-faint)',
borderTop:
idx === 0 ? '1px solid var(--line-default)' : '1px solid var(--line-faint)',
}}
>
<Td>
<div className="font-display text-fg-default" style={{ letterSpacing: '0.06em' }}>
{eng.codename}
<div
className="font-display text-fg-default"
style={{ letterSpacing: '0.06em' }}
>
{eng.client_name}
</div>
<div className="font-mono text-fg-faint" style={{ fontSize: '10.5px' }}>
{eng.id}
</div>
</Td>
<Td>{eng.client}</Td>
<Td>
<Pill tone={STATUS_TONE[eng.status]}>
<span className="status-dot" style={{ color: 'currentColor' }} />
@@ -75,18 +115,28 @@ export function EngagementsPage() {
</Pill>
</Td>
<Td>
<span className="font-mono tabular">{eng.c2Type.toUpperCase()}</span>
</Td>
<Td align="right">
<span className="font-mono tabular">{eng.operators}</span>
</Td>
<Td align="right">
<span className="font-mono tabular">{eng.socAnalysts}</span>
<span className="font-mono tabular">{eng.c2_type.toUpperCase()}</span>
</Td>
<Td>
{eng.start_date || eng.end_date ? (
<span className="font-mono tabular text-fg-muted">
{eng.startDate} {eng.endDate}
{eng.start_date ?? '—'} {eng.end_date ?? '—'}
</span>
) : (
<span className="text-fg-faint"></span>
)}
</Td>
<Td>
{eng.description ? (
<span
className="text-fg-muted"
style={{ display: 'inline-block', maxWidth: 280 }}
>
{eng.description}
</span>
) : (
<span className="text-fg-faint"></span>
)}
</Td>
<Td align="right">
<Link to="/runs">
@@ -99,7 +149,51 @@ export function EngagementsPage() {
))}
</tbody>
</table>
</Panel>
);
}
function LoadingRow() {
return (
<div className="px-4 py-12 flex items-center gap-3 text-fg-faint label-system">
<span className="status-dot text-fg-faint pulsing" />
<span>fetching engagements </span>
</div>
);
}
function ErrorRow({ error, onRetry }: { error: unknown; onRetry: () => void }) {
const message =
error instanceof ApiClientError
? `HTTP ${String(error.status)} · ${error.message}`
: 'Unable to reach the backend.';
return (
<div className="px-4 py-8 flex items-center justify-between gap-4">
<div>
<div className="label-system" style={{ color: 'var(--state-failed)' }}>
// Fetch failed
</div>
<div className="text-fg-muted mt-1" style={{ fontSize: 12.5 }}>
{message}
</div>
</div>
<Button variant="ghost" size="sm" onClick={onRetry}>
Retry
</Button>
</div>
);
}
function EmptyRow({ onCreate }: { onCreate: () => void }) {
return (
<div className="px-4 py-12 flex flex-col items-start gap-3">
<div className="label-system">// No engagements yet</div>
<p className="text-fg-muted" style={{ fontSize: 12.5 }}>
Create your first engagement to start composing scenarios and running them against client
infrastructure.
</p>
<Button variant="primary" size="sm" onClick={onCreate}>
+ New engagement
</Button>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import { apiFetch } from '@/lib/api';
import type { Engagement, EngagementCreate } from '@/types/api';
export const ENGAGEMENTS_QUERY_KEY = ['engagements'] as const;
/**
* Trailing slash matches the backend's blueprint URL prefix exactly. Hitting
* `/engagements` (no slash) triggers a 308 redirect which some browsers drop
* the session cookie on — we go direct.
*/
const ENGAGEMENTS_PATH = '/engagements/';
export async function fetchEngagements(signal?: AbortSignal): Promise<Engagement[]> {
return apiFetch<Engagement[]>(ENGAGEMENTS_PATH, { signal });
}
export async function createEngagement(payload: EngagementCreate): Promise<Engagement> {
return apiFetch<Engagement>(ENGAGEMENTS_PATH, { method: 'POST', body: payload });
}

View File

@@ -0,0 +1,63 @@
import { describe, it, expect, afterEach } from 'vitest';
import { screen, fireEvent, waitFor } from '@testing-library/react';
import { LoginPage } from './LoginPage';
import { installFetchMock, renderWithProviders } from '@/test/testUtils';
describe('LoginPage', () => {
let fetchMock: ReturnType<typeof installFetchMock>;
afterEach(() => {
fetchMock?.restore();
});
it('submits credentials and seeds session cache on success', async () => {
fetchMock = installFetchMock([
{
status: 200,
body: {
id: 'usr_1',
username: 'alice',
display_name: 'Alice',
role: 'rt_lead',
},
},
]);
renderWithProviders(<LoginPage />);
fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'alice' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'hunter2' } });
fireEvent.click(screen.getByRole('button', { name: /enter mimic/i }));
await waitFor(() => {
expect(fetchMock.calls).toHaveLength(1);
});
expect(fetchMock.calls[0]?.url).toBe('/api/v1/auth/login');
const init = fetchMock.calls[0]?.init;
expect(init?.method).toBe('POST');
expect(init?.credentials).toBe('include');
const bodyStr = typeof init?.body === 'string' ? init.body : '';
expect(JSON.parse(bodyStr)).toEqual({ username: 'alice', password: 'hunter2' });
});
it('shows a generic error on 401 without leaking server detail', async () => {
fetchMock = installFetchMock([{ status: 401, body: { detail: 'user_not_found' } }]);
renderWithProviders(<LoginPage />);
fireEvent.change(screen.getByLabelText(/username/i), { target: { value: 'alice' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'wrong' } });
fireEvent.click(screen.getByRole('button', { name: /enter mimic/i }));
const alert = await screen.findByRole('alert');
expect(alert.textContent).toMatch(/identifiants invalides/i);
expect(alert.textContent).not.toMatch(/user_not_found/i);
});
it('does not call the backend on empty submit (HTML5 required intercepts)', () => {
fetchMock = installFetchMock([]);
renderWithProviders(<LoginPage />);
fireEvent.click(screen.getByRole('button', { name: /enter mimic/i }));
expect(fetchMock.calls).toHaveLength(0);
});
});

View File

@@ -1,42 +1,68 @@
import { useState, type FormEvent, type ReactNode } from 'react';
import { useState, type FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Logo } from '@/components/brand/Logo';
import { Button } from '@/components/ui/Button';
import { Pill } from '@/components/ui/Pill';
import { useSession } from '@/session/useSession';
import { MOCK_SESSIONS } from '@/mocks/session';
import type { Role } from '@/types/roles';
import { ApiClientError } from '@/lib/api';
import { login } from '@/session/sessionApi';
import { SESSION_QUERY_KEY } from '@/session/useSession';
type Mode = 'rt' | 'soc';
/**
* Login screen — two distinct paths.
*
* RT operators authenticate via username/password (D-003 v1, OIDC v2).
* SOC analysts use a one-shot token (D-006: bcrypt-hashed soc_session,
* clear value delivered out-of-band by the lead RT).
* Sprint 1 wires the RT operator path against POST /api/v1/auth/login.
* The SOC analyst path stays visible (so the masthead doesn't change) but
* is deferred to sprint 2 when the backend exposes /auth/soc/session.
*
* Sprint 0 mock: no validation. Picking a role assumes that role's mock
* session and lands the user inside the shell.
* On success the server sets an HttpOnly session cookie; the response body
* is the User. We seed the TanStack Query cache directly so the next render
* pass already has the user, then navigate to /engagements (the post-login
* landing for RT). No localStorage, no client-side credential persistence.
*
* Error policy: we never echo the backend message verbatim (could leak
* "user not found" vs "wrong password"). 401 → generic "Identifiants
* invalides". 4xx/5xx other → generic "Connexion impossible".
*/
export function LoginPage() {
const [mode, setMode] = useState<Mode>('rt');
const [pickedRtRole, setPickedRtRole] = useState<'rt_operator' | 'rt_lead'>('rt_lead');
const { signIn } = useSession();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [errorMsg, setErrorMsg] = useState<string | null>(null);
const navigate = useNavigate();
const queryClient = useQueryClient();
const loginMutation = useMutation({
mutationFn: login,
onSuccess: (user) => {
queryClient.setQueryData(SESSION_QUERY_KEY, user);
setErrorMsg(null);
void navigate('/engagements', { replace: true });
},
onError: (err) => {
if (err instanceof ApiClientError && err.status === 401) {
setErrorMsg('Identifiants invalides.');
} else {
setErrorMsg('Connexion impossible. Réessayez dans un instant.');
}
},
});
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const role: Role = mode === 'soc' ? 'soc_analyst' : pickedRtRole;
const user = MOCK_SESSIONS[role];
if (!user) return;
signIn(user);
void navigate(mode === 'soc' ? '/runs' : '/engagements', { replace: true });
if (mode !== 'rt') return;
if (!username || !password) {
setErrorMsg('Identifiant et mot de passe requis.');
return;
}
loginMutation.mutate({ username, password });
};
return (
<div className="min-h-screen flex" style={{ backgroundColor: 'var(--surface-0)' }}>
{/* Left rail — masthead, identity, telemetry of the platform itself */}
<aside
className="hidden lg:flex flex-col justify-between w-[420px] border-r p-10"
style={{
@@ -61,18 +87,14 @@ export function LoginPage() {
</div>
<div className="space-y-3 font-mono tabular text-fg-faint" style={{ fontSize: '11px' }}>
<div className="flex gap-3">
<span className="w-24 text-fg-subtle">spec</span>
<span>ready-with-prereqs · frozen 2026-05-19</span>
</div>
<div className="flex gap-3">
<span className="w-24 text-fg-subtle">blockers</span>
<span>PR1 · PR2 · PR3 (graphic charter)</span>
</div>
<div className="flex gap-3">
<span className="w-24 text-fg-subtle">deploy</span>
<span>RT infra · Caddy + TLS · OPSEC handled by RP</span>
</div>
<div className="flex gap-3">
<span className="w-24 text-fg-subtle">auth</span>
<span>local user/password v1 · OIDC v2</span>
</div>
</div>
</div>
@@ -82,10 +104,8 @@ export function LoginPage() {
</div>
</aside>
{/* Right column — the auth form, instrument-panel inset card */}
<div className="flex-1 flex items-center justify-center px-8 py-16">
<div className="w-full max-w-[420px]">
{/* Mode switch — segmented, role-tinted */}
<div className="flex items-center gap-1 mb-6" role="tablist" aria-label="Login mode">
<ModeTab
active={mode === 'rt'}
@@ -99,7 +119,7 @@ export function LoginPage() {
onClick={() => setMode('soc')}
tone="soc"
label="SOC — analyst"
hint="session token"
hint="session token (sprint 2)"
/>
</div>
@@ -114,24 +134,64 @@ export function LoginPage() {
}}
>
<div className="flex items-center justify-between mb-4">
<h1 className="font-display text-fg-default" style={{ fontSize: '15px', letterSpacing: '0.05em' }}>
<h1
className="font-display text-fg-default"
style={{ fontSize: '15px', letterSpacing: '0.05em' }}
>
{mode === 'rt' ? 'Operator sign-in' : 'SOC session'}
</h1>
<Pill tone={mode}>{mode === 'rt' ? 'RT' : 'SOC'}</Pill>
</div>
{mode === 'rt' ? (
<RtForm picked={pickedRtRole} onPick={setPickedRtRole} />
<div className="space-y-4">
<Field
label="Email or username"
name="username"
autoComplete="username"
value={username}
onChange={setUsername}
disabled={loginMutation.isPending}
/>
<Field
label="Password"
name="password"
type="password"
autoComplete="current-password"
value={password}
onChange={setPassword}
disabled={loginMutation.isPending}
/>
</div>
) : (
<SocForm />
<SocPlaceholder />
)}
{errorMsg && mode === 'rt' && (
<p
role="alert"
className="mt-4 label-system"
style={{
color: 'var(--state-failed)',
border: '1px solid var(--state-failed)',
padding: '6px 10px',
borderRadius: 'var(--radius-sm)',
}}
>
{errorMsg}
</p>
)}
<div className="mt-6 flex items-center justify-between gap-3">
<p className="label-system text-fg-faint">
{mode === 'rt' ? 'OIDC Keycloak — v2' : 'Token delivered out-of-band'}
</p>
<Button type="submit" variant="primary">
{mode === 'rt' ? 'Enter Mimic →' : 'Open session →'}
<Button
type="submit"
variant="primary"
disabled={mode !== 'rt' || loginMutation.isPending}
>
{loginMutation.isPending ? 'Authenticating …' : 'Enter Mimic →'}
</Button>
</div>
</form>
@@ -140,7 +200,7 @@ export function LoginPage() {
className="mt-6 font-mono text-fg-faint text-center"
style={{ fontSize: '10.5px', letterSpacing: '0.08em' }}
>
mimic.rt.local · session ssrf-protected · audit log live
mimic.rt.local · session cookie HttpOnly · audit log live
</p>
</div>
</div>
@@ -178,7 +238,13 @@ function ModeTab({ active, onClick, tone, label, hint }: ModeTabProps) {
>
<div
className="label-system"
style={{ color: active ? (tone === 'rt' ? 'var(--accent-rt)' : 'var(--accent-soc)') : 'var(--fg-subtle)' }}
style={{
color: active
? tone === 'rt'
? 'var(--accent-rt)'
: 'var(--accent-soc)'
: 'var(--fg-subtle)',
}}
>
{label}
</div>
@@ -189,75 +255,47 @@ function ModeTab({ active, onClick, tone, label, hint }: ModeTabProps) {
);
}
function RtForm({
picked,
onPick,
}: {
picked: 'rt_operator' | 'rt_lead';
onPick: (r: 'rt_operator' | 'rt_lead') => void;
}) {
function SocPlaceholder() {
return (
<div className="space-y-4">
<Field label="Username" placeholder="m.dubreuil" autoComplete="username" />
<Field label="Password" type="password" placeholder="••••••••••" autoComplete="current-password" />
<fieldset className="space-y-2">
<legend className="label-system">Mock role (sprint 0 only)</legend>
<div className="flex gap-2">
<RolePick active={picked === 'rt_operator'} onClick={() => onPick('rt_operator')}>
RT Operator
</RolePick>
<RolePick active={picked === 'rt_lead'} onClick={() => onPick('rt_lead')}>
RT Lead
</RolePick>
</div>
</fieldset>
</div>
);
}
function SocForm() {
return (
<div className="space-y-4">
<Field
label="Session token"
placeholder="msoc_xxxxx-xxxx-xxxx-xxxx"
autoComplete="off"
spellCheck={false}
mono
/>
<p className="font-mono text-fg-faint" style={{ fontSize: '10.5px', lineHeight: 1.5 }}>
Your token was delivered out-of-band by the lead RT.
<p
className="font-mono text-fg-faint"
style={{ fontSize: '11px', lineHeight: 1.55 }}
>
SOC session sign-in lands in sprint 2 once the backend exposes
<br />
Scope: <span className="text-fg-muted">single engagement, read-only on telemetry, write on detection coting.</span>
<code style={{ fontSize: '10.5px' }}>POST /api/v1/auth/soc/session</code>.
<br />
For now, use the RT path with a local account.
</p>
</div>
);
}
interface FieldProps {
label: string;
placeholder?: string;
name: string;
type?: string;
autoComplete?: string;
spellCheck?: boolean;
mono?: boolean;
value: string;
onChange: (next: string) => void;
disabled?: boolean;
}
function Field({ label, placeholder, type = 'text', autoComplete, spellCheck, mono }: FieldProps) {
const id = label.toLowerCase().replace(/\s+/g, '-');
function Field({ label, name, type = 'text', autoComplete, value, onChange, disabled }: FieldProps) {
return (
<div>
<label htmlFor={id} className="block label-system mb-1.5">
<label htmlFor={name} className="block label-system mb-1.5">
{label}
</label>
<input
id={id}
name={id}
id={name}
name={name}
type={type}
placeholder={placeholder}
autoComplete={autoComplete}
spellCheck={spellCheck}
className={mono ? 'font-mono' : 'font-sans'}
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
required
className="font-sans"
style={{
width: '100%',
height: 32,
@@ -272,29 +310,3 @@ function Field({ label, placeholder, type = 'text', autoComplete, spellCheck, mo
</div>
);
}
function RolePick({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className="flex-1 label-system px-3 py-2 transition-colors"
style={{
background: active ? 'oklch(74.0% 0.165 68 / 0.10)' : 'transparent',
color: active ? 'var(--accent-rt)' : 'var(--fg-muted)',
border: `1px solid ${active ? 'var(--accent-rt-muted)' : 'var(--line-default)'}`,
borderRadius: 'var(--radius-sm)',
}}
>
{children}
</button>
);
}

View File

@@ -1,10 +0,0 @@
import { createContext } from 'react';
import type { SessionUser } from '@/types/roles';
export interface SessionContextValue {
user: SessionUser | null;
signIn: (user: SessionUser) => void;
signOut: () => void;
}
export const SessionContext = createContext<SessionContextValue | null>(null);

View File

@@ -1,25 +0,0 @@
import { useCallback, useMemo, useState, type ReactNode } from 'react';
import type { SessionUser } from '@/types/roles';
import { clearMockSession, readMockSession, writeMockSession } from '@/mocks/session';
import { SessionContext, type SessionContextValue } from './SessionContext.context';
export function SessionProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<SessionUser | null>(() => readMockSession());
const signIn = useCallback((next: SessionUser) => {
writeMockSession(next);
setUser(next);
}, []);
const signOut = useCallback(() => {
clearMockSession();
setUser(null);
}, []);
const value = useMemo<SessionContextValue>(
() => ({ user, signIn, signOut }),
[user, signIn, signOut],
);
return <SessionContext.Provider value={value}>{children}</SessionContext.Provider>;
}

View File

@@ -0,0 +1,32 @@
import { ApiClientError, apiFetch } from '@/lib/api';
import type { LoginRequest, User } from '@/types/api';
/**
* Network layer for session-scoped endpoints.
*
* Each function maps 1:1 to a backend endpoint and is the only place that
* knows the path. Components never call `apiFetch` directly for these —
* they go through TanStack Query against these helpers.
*/
/**
* GET /api/v1/auth/me — returns the current user if a valid session cookie
* is present. Maps the 401 case to `null` rather than re-throwing so the
* caller can treat "unauthenticated" as data, not as an error.
*/
export async function fetchMe(signal?: AbortSignal): Promise<User | null> {
try {
return await apiFetch<User>('/auth/me', { signal });
} catch (err) {
if (err instanceof ApiClientError && err.status === 401) return null;
throw err;
}
}
export async function login(payload: LoginRequest): Promise<User> {
return apiFetch<User>('/auth/login', { method: 'POST', body: payload });
}
export async function logout(): Promise<void> {
await apiFetch<null>('/auth/logout', { method: 'POST' });
}

View File

@@ -1,10 +1,52 @@
import { useContext } from 'react';
import { SessionContext } from './SessionContext.context';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import type { User } from '@/types/api';
import { fetchMe, logout as logoutRequest } from './sessionApi';
export function useSession() {
const ctx = useContext(SessionContext);
if (!ctx) {
throw new Error('useSession must be used inside <SessionProvider>');
}
return ctx;
export const SESSION_QUERY_KEY = ['session'] as const;
interface UseSessionResult {
user: User | null;
isLoading: boolean;
isError: boolean;
signOut: () => Promise<void>;
isSigningOut: boolean;
}
/**
* Single source of truth for the current session.
*
* Backed by TanStack Query against /api/v1/auth/me. Components never read
* from sessionStorage or any local state — the cookie is the source, the
* server is the resolver, the query is the cache.
*
* After successful login the LoginPage calls `queryClient.setQueryData` on
* `SESSION_QUERY_KEY` with the returned User, which propagates to every
* consumer instantly.
*/
export function useSession(): UseSessionResult {
const queryClient = useQueryClient();
const query = useQuery<User | null>({
queryKey: SESSION_QUERY_KEY,
queryFn: ({ signal }) => fetchMe(signal),
staleTime: 60_000,
retry: false,
});
const logoutMutation = useMutation({
mutationFn: logoutRequest,
onSuccess: () => {
queryClient.setQueryData(SESSION_QUERY_KEY, null);
},
});
return {
user: query.data ?? null,
isLoading: query.isLoading,
isError: query.isError,
signOut: async () => {
await logoutMutation.mutateAsync();
},
isSigningOut: logoutMutation.isPending,
};
}

View File

@@ -0,0 +1,75 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MemoryRouter } from 'react-router-dom';
import { render, type RenderOptions } from '@testing-library/react';
import type { ReactElement, ReactNode } from 'react';
/**
* Test harness: a fresh QueryClient per render (no retries, no caching) +
* a MemoryRouter so screens that call `useNavigate` / `<Link>` don't crash.
*
* Components under test never reach the real network — tests stub
* `globalThis.fetch` before render.
*/
export function renderWithProviders(
ui: ReactElement,
options?: { route?: string; queryClient?: QueryClient } & RenderOptions,
) {
const client =
options?.queryClient ??
new QueryClient({
defaultOptions: {
queries: { retry: false, gcTime: 0, staleTime: 0 },
mutations: { retry: false },
},
});
function Wrapper({ children }: { children: ReactNode }) {
return (
<QueryClientProvider client={client}>
<MemoryRouter initialEntries={[options?.route ?? '/']}>{children}</MemoryRouter>
</QueryClientProvider>
);
}
return { client, ...render(ui, { wrapper: Wrapper, ...options }) };
}
/**
* Tiny typed fetch mock. Replaces globalThis.fetch with a sequence of
* recorded responses, in call order. Tests `restore()` in afterEach.
*/
export function installFetchMock(responses: Array<{ status: number; body?: unknown }>) {
const calls: Array<{ url: string; init?: RequestInit }> = [];
const queue = [...responses];
const original = globalThis.fetch;
const mock: typeof fetch = (input, init) => {
const url = inputToUrl(input);
calls.push({ url, init });
const next = queue.shift();
if (!next) {
return Promise.reject(new Error(`Unexpected extra fetch call to ${url}`));
}
const body = next.body === undefined ? '' : JSON.stringify(next.body);
return Promise.resolve(
new Response(body, {
status: next.status,
headers: { 'Content-Type': 'application/json' },
}),
);
};
globalThis.fetch = mock;
return {
calls,
restore: () => {
globalThis.fetch = original;
},
};
}
function inputToUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') return input;
if (input instanceof URL) return input.toString();
return input.url;
}

84
frontend/src/types/api.ts Normal file
View File

@@ -0,0 +1,84 @@
/**
* Shared API contract types.
*
* Hand-rolled against the backend Pydantic schemas as documented in
* `docs/api.md` (sprint 1, feature/backend-auth-wiring @ dd321c2). Once
* the backend exposes OpenAPI, this file should be regenerated rather
* than maintained by hand.
*
* Wire format is snake_case; the frontend keeps the same casing so there
* is no camelCase adapter layer to drift.
*/
import type { Role } from './roles';
/**
* `CurrentUser` payload returned by /auth/login and /auth/me.
*
* `username` carries the email server-side (kept as "username" in the
* HTTP contract so future identity sources can route through the same
* endpoint). `permissions` is the canonical RBAC resolution from the
* backend — the source of truth for action gating.
*/
export interface User {
user_id: string;
username: string;
display_name: string | null;
role: Role;
permissions: string[];
groups: string[];
}
export interface LoginRequest {
username: string;
password: string;
}
export type C2Type = 'mythic' | 'home';
export type EngagementStatus = 'draft' | 'active' | 'closed' | 'archived';
/**
* Engagement read shape (list element and detail).
*
* The backend does not expose a separate `name` column — the
* `client_name` is the primary identifier. The frontend treats
* `client_name` as both the display label and the form's required field.
*/
export interface Engagement {
id: string;
client_name: string;
description: string | null;
status: EngagementStatus;
c2_type: C2Type;
start_date: string | null;
end_date: string | null;
}
export interface EngagementCreate {
client_name: string;
description?: string | null;
c2_type?: C2Type;
start_date?: string | null;
end_date?: string | null;
}
/**
* Uniform error envelope (docs/api.md §Conventions).
*
* `error` is a stable snake_case code, `message` is human-readable but
* not localized. `details` carries Pydantic per-field errors on 422
* once the backend's global HTTPException handler is in place (currently
* Flask emits HTML on raw `abort(422, ...)` — pending backend ack on the
* outstanding ping; the field is optional to absorb either shape).
*/
export interface ApiError {
error: string;
message: string;
details?: PydanticErrorItem[];
}
export interface PydanticErrorItem {
loc: Array<string | number>;
msg: string;
type: string;
}

View File

@@ -5,14 +5,6 @@
*/
export type Role = 'rt_operator' | 'rt_lead' | 'soc_analyst';
export interface SessionUser {
id: string;
displayName: string;
role: Role;
engagementId: string;
engagementName: string;
}
export const ROLE_LABELS: Record<Role, string> = {
rt_operator: 'RT Operator',
rt_lead: 'RT Lead',

View File

@@ -3,6 +3,8 @@ import react from '@vitejs/plugin-react';
import tailwindcss from '@tailwindcss/vite';
import path from 'node:path';
const API_TARGET = process.env.VITE_DEV_API_TARGET ?? 'http://localhost:5000';
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
@@ -13,5 +15,12 @@ export default defineConfig({
server: {
port: 5173,
strictPort: false,
proxy: {
'/api': {
target: API_TARGET,
changeOrigin: true,
secure: false,
},
},
},
});

View File

@@ -152,3 +152,54 @@ extension owns the registry).
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
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).

View File

@@ -6,7 +6,7 @@ Repo skeleton + foundational modules. Nothing that depends on PR1/PR2/PR3.
- [x] B0.1 — `backend/` Python 3.12+ project: `pyproject.toml` (ruff, mypy strict, pytest,
coverage 70 %), `Makefile` (Docker/Podman auto), multi-stage `Dockerfile`,
`docker-compose.yml` for Postgres dev DB, `.env.example`.
`compose.yml` for Postgres dev DB, `.env.example`.
- [x] B0.2 — Alembic baseline migration `202605210001_initial_schema` creates every table,
enum, index, and the idempotent grants for the audit write-only Postgres role. **No
`ttp_version` table** (D-009). Groups `rt_operator`, `rt_lead`, `soc_analyst` seeded
@@ -111,6 +111,58 @@ Tracked from code-review verdict on `feature/backend-skeleton` @ 12d131c:
- [ ] R0.2 — Verify mypy strict and ruff clean before approving any backend PR.
- [ ] R0.3 — Verify TS strict, no `useEffect(fetch)`, exhaustive deps before approving any frontend PR.
## CI follow-ups (sprint 1+) (`devops`)
Raised by `code-reviewer` during review of `chore/podman-and-ci` (M2-M3 + N1-N6).
None blocking, all deferred to sprint 1+.
- [ ] M2 — `backend/Makefile` `$(COMPOSE)` detection: invert legacy `docker-compose` v1
probe, prefer the Compose v2 plugin (`$(CONTAINER) compose`) first.
- [ ] M3 — `.gitea/workflows/ci.yml` backend job: chain `apt-get update && apt-get install`
in one `RUN`-style step and drop `rm -rf /var/lib/apt/lists/*` (no-op in an
ephemeral CI container).
- [ ] N1 — Smoke workflow `cat /etc/os-release | head -3` → use `head -3 /etc/os-release`
(moot once smoke.yml is removed; track here in case smoke is reintroduced).
- [ ] N2 — `.gitea/workflows/ci.yml` `pull_request:` trigger: restrict to `branches: [main]`
to avoid double-running on PR retargets.
- [ ] N3 — Anticipate single-runner serialization: jobs will queue. Plan a second
runner (different host or `capacity: >1`) before scaling sprint 2+ workload.
- [ ] N4 — Add top-level `concurrency: { group: ${{ github.ref }}, cancel-in-progress: true }`
to cancel superseded PR runs.
- [ ] N5 — CI uses `MIMIC_DATABASE_AUDIT_URL == MIMIC_DATABASE_URL` (same role).
Acceptable for unit tests; integration tests covering the audit write-only
role must provision a separate `mimic_audit_writer` role in the Postgres
service before they can run.
- [ ] N6 — Cache pip + npm via `actions/cache@v4` (verify Gitea Actions fork support
before adoption; fallback to manual cache volume on the runner if unsupported).
- [ ] FERNET-KEY — Provision `FERNET_KEY_TEST` Gitea repo secret before sprint 1
wires `c2_credential.config_fernet` (D-004). `config.py:32` accepts an empty
default at boot but `Fernet(b"")` raises `ValueError` at first use.
## Frontend follow-ups (sprint 1+) (`devops`)
Raised by `code-reviewer` during review of `chore/frontend-dockerfile`
(`649194b`). None blocking, all deferred to sprint 1+. Some have a project-
wide reach (F-D1 also covers the backend image and the runner image).
- [ ] F-D1 — Pin container images by minor + digest, not by tag alone. Scope :
`frontend/Dockerfile` (node:22-alpine, nginxinc/nginx-unprivileged:alpine),
`backend/Dockerfile` (python:3.12-slim-bookworm), and the runner image
referenced in `docs/podman-runner-setup.md` (gitea/act_runner:X.Y.Z).
Harmonise the policy in a single chore commit.
- [ ] F-D2 — Decide image-level `HEALTHCHECK` directives vs delegating health
probing to Caddy upstream. Document the choice in `docs/deploy.md`.
- [ ] F-D3 — Security response headers (`X-Content-Type-Options: nosniff`,
`Referrer-Policy: strict-origin-when-cross-origin`, `Content-Security-Policy`).
Arbitrate ownership between `frontend/nginx.conf` and the Caddy outer
layer to avoid duplication / conflict.
- [ ] F-D4 — Enable response compression. Either `gzip on` + `gzip_types` in
`frontend/nginx.conf` (runtime), or `vite-plugin-compression` (precompute
.br / .gz at build time, served via `gzip_static`). Pick one.
- [ ] F-D5 — OCI image labels (`org.opencontainers.image.source`,
`image.title`, `image.licenses`, `image.revision`) on every Dockerfile.
Useful for registry metadata and supply-chain attestation tooling.
## Conventions
- Branches: `feature/<scope>`, `fix/<scope>`, `docs/<scope>`, `chore/<scope>`. Long-lived: `main`.