Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
140a34b81e | ||
|
|
ec7effcaac | ||
|
|
20fbcdf1f8 | ||
|
|
f6d4e43e4c | ||
|
|
dd5c508b04 | ||
|
|
dd321c2cd0 | ||
|
|
e1b381af4d | ||
|
|
38b35c933a | ||
|
|
a8c5400f97 | ||
|
|
c44f8b90ad | ||
|
|
649194b174 | ||
|
|
359225e464 | ||
|
|
df6294ed7b | ||
|
|
1380672c03 | ||
|
|
9ece352659 |
97
.gitea/workflows/ci.yml
Normal file
97
.gitea/workflows/ci.yml
Normal 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
|
||||
51
CHANGELOG.md
51
CHANGELOG.md
@@ -5,6 +5,55 @@ Versioning starts at `0.1.0` when sprint 0 lands.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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 +77,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`,
|
||||
|
||||
@@ -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';"
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.*",
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from flask import Flask
|
||||
|
||||
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
|
||||
@@ -11,6 +12,7 @@ from mimic.api.ttps import bp as ttps_bp
|
||||
|
||||
|
||||
def register_blueprints(app: Flask) -> None:
|
||||
app.register_blueprint(auth_bp, url_prefix="/api/v1/auth")
|
||||
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")
|
||||
|
||||
@@ -11,8 +11,10 @@ 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
|
||||
|
||||
|
||||
def parse_body[T: BaseModel](model: type[T]) -> T:
|
||||
@@ -50,6 +52,23 @@ 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 audit_write(
|
||||
*,
|
||||
action: str,
|
||||
|
||||
101
backend/src/mimic/api/auth.py
Normal file
101
backend/src/mimic/api/auth.py
Normal 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
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Pydantic 2 request/response DTOs."""
|
||||
|
||||
from mimic.schemas.auth import CurrentUser, LoginRequest
|
||||
from mimic.schemas.engagement import (
|
||||
EngagementCreate,
|
||||
EngagementRead,
|
||||
@@ -16,12 +17,14 @@ from mimic.schemas.scenario import (
|
||||
from mimic.schemas.ttp import TtpCreate, TtpRead, TtpUpdate
|
||||
|
||||
__all__ = [
|
||||
"CurrentUser",
|
||||
"EngagementCreate",
|
||||
"EngagementRead",
|
||||
"EngagementUpdate",
|
||||
"HostCreate",
|
||||
"HostRead",
|
||||
"HostUpdate",
|
||||
"LoginRequest",
|
||||
"ScenarioCreate",
|
||||
"ScenarioRead",
|
||||
"ScenarioStepCreate",
|
||||
|
||||
32
backend/src/mimic/schemas/auth.py
Normal file
32
backend/src/mimic/schemas/auth.py
Normal 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]
|
||||
202
backend/tests/integration/test_auth_engagement_e2e.py
Normal file
202
backend/tests/integration/test_auth_engagement_e2e.py
Normal 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"
|
||||
71
backend/tests/unit/test_auth_schemas.py
Normal file
71
backend/tests/unit/test_auth_schemas.py
Normal 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
|
||||
191
docs/api.md
Normal file
191
docs/api.md
Normal file
@@ -0,0 +1,191 @@
|
||||
# Mimic API — sprint 1 surface
|
||||
|
||||
This document covers the endpoints the frontend is expected to call in sprint 1.
|
||||
Everything is JSON, every protected route relies on the Flask session cookie set
|
||||
by `POST /api/v1/auth/login`. CORS is enabled only when `MIMIC_ENV=development`
|
||||
and `MIMIC_CORS_ORIGINS` is set (the prod reverse proxy serves the SPA on the
|
||||
same origin).
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Base URL**: `/api/v1`.
|
||||
- **Trailing slash**: routes accept the URL with **or without** a trailing
|
||||
slash. The app is configured with `strict_slashes=False`, so Werkzeug
|
||||
never issues a 308 redirect (which can drop the session cookie on some
|
||||
browsers). Use whichever form your client prefers; `docs/api.md` writes
|
||||
the no-slash form.
|
||||
- **Auth transport**: Flask session cookie (`HttpOnly`, `SameSite=Lax`,
|
||||
`Secure` in production). The browser must send `credentials: "include"`
|
||||
on every request.
|
||||
- **Content negotiation**: requests and responses use `application/json`.
|
||||
- **Error envelope**: **every** failure returns the same shape, served by a
|
||||
global `HTTPException` handler:
|
||||
```json
|
||||
{ "error": "<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.
|
||||
|
||||
## 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.
|
||||
|
||||
## 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.
|
||||
@@ -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
274
docs/deploy.md
Normal 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
262
docs/podman-runner-setup.md
Normal 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
22
frontend/.dockerignore
Normal 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
37
frontend/Dockerfile
Normal 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
30
frontend/nginx.conf
Normal 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;
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
97
frontend/src/lib/api.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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([
|
||||
{ index: true, element: <Navigate to="/engagements" replace /> },
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{
|
||||
element: <Root />,
|
||||
element: <AppShell />,
|
||||
children: [
|
||||
{ index: true, element: <Navigate to="/login" 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 /> },
|
||||
]);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
517
frontend/src/screens/engagements/EngagementCreateDialog.tsx
Normal file
517
frontend/src/screens/engagements/EngagementCreateDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
frontend/src/screens/engagements/EngagementsPage.test.tsx
Normal file
60
frontend/src/screens/engagements/EngagementsPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,79 +42,158 @@ 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>
|
||||
}
|
||||
>
|
||||
<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 />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{MOCK_ENGAGEMENTS.map((eng, idx) => (
|
||||
<tr
|
||||
key={eng.id}
|
||||
style={{
|
||||
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>
|
||||
<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' }} />
|
||||
{eng.status}
|
||||
</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>
|
||||
</Td>
|
||||
<Td>
|
||||
<span className="font-mono tabular text-fg-muted">
|
||||
{eng.startDate} → {eng.endDate}
|
||||
</span>
|
||||
</Td>
|
||||
<Td align="right">
|
||||
<Link to="/runs">
|
||||
<Button variant="ghost" size="sm">
|
||||
Enter →
|
||||
</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{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>Client</Th>
|
||||
<Th>Status</Th>
|
||||
<Th>C2</Th>
|
||||
<Th>Window</Th>
|
||||
<Th>Description</Th>
|
||||
<Th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{engagements.map((eng, idx) => (
|
||||
<tr
|
||||
key={eng.id}
|
||||
style={{
|
||||
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.client_name}
|
||||
</div>
|
||||
<div className="font-mono text-fg-faint" style={{ fontSize: '10.5px' }}>
|
||||
{eng.id}
|
||||
</div>
|
||||
</Td>
|
||||
<Td>
|
||||
<Pill tone={STATUS_TONE[eng.status]}>
|
||||
<span className="status-dot" style={{ color: 'currentColor' }} />
|
||||
{eng.status}
|
||||
</Pill>
|
||||
</Td>
|
||||
<Td>
|
||||
<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.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">
|
||||
<Button variant="ghost" size="sm">
|
||||
Enter →
|
||||
</Button>
|
||||
</Link>
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
19
frontend/src/screens/engagements/engagementsApi.ts
Normal file
19
frontend/src/screens/engagements/engagementsApi.ts
Normal 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 });
|
||||
}
|
||||
63
frontend/src/screens/login/LoginPage.test.tsx
Normal file
63
frontend/src/screens/login/LoginPage.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
<br />
|
||||
Scope: <span className="text-fg-muted">single engagement, read-only on telemetry, write on detection coting.</span>
|
||||
</p>
|
||||
</div>
|
||||
<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 />
|
||||
<code style={{ fontSize: '10.5px' }}>POST /api/v1/auth/soc/session</code>.
|
||||
<br />
|
||||
For now, use the RT path with a local account.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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>;
|
||||
}
|
||||
32
frontend/src/session/sessionApi.ts
Normal file
32
frontend/src/session/sessionApi.ts
Normal 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' });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
75
frontend/src/test/testUtils.tsx
Normal file
75
frontend/src/test/testUtils.tsx
Normal 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
84
frontend/src/types/api.ts
Normal 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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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`.
|
||||
|
||||
Reference in New Issue
Block a user