feat: sprint 1 — auth + CRUD engagements

Ship the first feature end-to-end on the UI: users log in with JWT,
admins manage user accounts, and any authenticated user (per RBAC)
can create, list, view, edit, and delete engagements.

Backend (Flask + SQLAlchemy + SQLite, 63 pytest)
- User / Engagement models, Alembic 0001 initial schema
- argon2 password hashing, JWT bearer (60-min TTL), @login_required
  and @role_required decorators
- 13 API endpoints under /api/*, including last-admin protection on
  DELETE/PATCH user and JSON 404 on unknown /api/* paths
- `flask create-admin` CLI with duplicate / short-password handling

Frontend (React + Vite + Tailwind + TanStack Query, 20 vitest)
- Inter font bundled locally (no CDN), DESIGN.md tokens in Tailwind
- LoginPage / EngagementsList / EngagementForm / EngagementDetail /
  UsersAdmin pages with role-aware UI
- Layout, ProtectedRoute, StatusBadge, FormField, LoadingState,
  ErrorState, EmptyState, Toast + provider
- Axios client: Bearer interceptor, 401 → purge + /login + "Session
  expirée" toast, 403 → "Accès refusé" toast (declarative <Navigate>
  for already-authed users, Fragment-keyed admin user rows)

Deployment
- Single multistage Dockerfile (node:20-alpine → python:3.12-slim)
- docker/entrypoint.sh runs `flask db upgrade` before `flask run`
- Makefile: build/start/stop/restart/update/logs/create-admin/
  update-mitre/test-{backend,frontend,e2e}/clean
- .env.example documenting MIMIC_JWT_SECRET / MIMIC_DB_PATH / MIMIC_PORT
- SQLite at /data/mimic.sqlite on named volume mimic-data

Acceptance suite (Playwright, 36 tests, all 27 ACs)
- e2e/ scaffold with playwright.config + auth/api fixtures
- One spec per user story (us1-bootstrap through us6-deployment)
- Portable via MIMIC_CONTAINER_CMD / MIMIC_BASE_URL (docker or podman)

Docs
- README.md with quick-start and architecture overview
- CHANGELOG.md updated with Sprint 1 deliverables
- pyrightconfig.json so the Python LSP sees backend/.venv and
  resolves the `backend.app.*` absolute imports

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Knacky
2026-05-26 09:37:53 +02:00
parent be266d4879
commit 5104f7c429
95 changed files with 13801 additions and 5 deletions

71
backend/app/__init__.py Normal file
View File

@@ -0,0 +1,71 @@
"""Flask application factory."""
from __future__ import annotations
import os
from pathlib import Path
from flask import Flask, jsonify, send_from_directory
from backend.app.api import auth_bp, engagements_bp, users_bp
from backend.app.cli import register_cli
from backend.app.config import Config, TestConfig
from backend.app.errors import register_error_handlers
from backend.app.extensions import db, migrate
def create_app(config_object: object | None = None) -> Flask:
"""Application factory.
`config_object` is an *instance* (not a class) of Config or a subclass.
If None, picks TestConfig when MIMIC_TESTING=1, otherwise Config.
"""
static_folder = str(Path(__file__).parent / "static")
app = Flask(__name__, static_folder=static_folder, static_url_path="/static")
if config_object is None:
config_object = TestConfig() if os.environ.get("MIMIC_TESTING") == "1" else Config()
app.config.from_object(config_object)
db.init_app(app)
migrations_dir = str(Path(__file__).parent.parent / "migrations")
migrate.init_app(app, db, directory=migrations_dir)
# Ensure models are imported so Alembic/metadata see them.
from backend.app import models # noqa: F401
app.register_blueprint(auth_bp)
app.register_blueprint(users_bp)
app.register_blueprint(engagements_bp)
register_error_handlers(app)
register_cli(app)
static_root = Path(static_folder)
@app.get("/api/health")
def health():
return {"status": "ok"}, 200
# Serve the built frontend (Vite output copied into app/static at image build time).
@app.get("/")
def index():
index_path = static_root / "index.html"
if index_path.exists():
return send_from_directory(static_folder, "index.html")
return {"status": "ok", "message": "Mimic API running. Frontend not built."}, 200
@app.get("/<path:path>")
def spa_fallback(path: str):
# Unknown /api/* paths must stay JSON 404 — never shadowed by index.html.
if path.startswith("api/"):
return jsonify({"error": "Not found"}), 404
# Serve static assets if present; otherwise hand back index.html for client routing.
candidate = static_root / path
if candidate.is_file():
return send_from_directory(static_folder, path)
index_path = static_root / "index.html"
if index_path.exists():
return send_from_directory(static_folder, "index.html")
return jsonify({"error": "Not found"}), 404
return app

View File

@@ -0,0 +1,6 @@
"""API blueprints."""
from backend.app.api.auth import auth_bp
from backend.app.api.engagements import engagements_bp
from backend.app.api.users import users_bp
__all__ = ["auth_bp", "users_bp", "engagements_bp"]

46
backend/app/api/auth.py Normal file
View File

@@ -0,0 +1,46 @@
"""Auth endpoints: login, logout, me."""
from __future__ import annotations
from flask import Blueprint, g, jsonify, request
from backend.app.auth import encode_token, login_required, verify_password
from backend.app.models import User
from backend.app.serializers import serialize_user
auth_bp = Blueprint("auth", __name__, url_prefix="/api/auth")
@auth_bp.post("/login")
def login():
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
generic_error = (jsonify({"error": "Invalid credentials"}), 401)
if not username or not password:
return generic_error
user = User.query.filter_by(username=username).first()
if user is None or not verify_password(user.password_hash, password):
return generic_error
token = encode_token(user.id, user.role.value)
return jsonify(
{
"access_token": token,
"user": {"id": user.id, "username": user.username, "role": user.role.value},
}
), 200
@auth_bp.post("/logout")
@login_required
def logout():
# V1: stateless JWT — client discards the token. No server-side blacklist.
return jsonify({"status": "ok"}), 200
@auth_bp.get("/me")
@login_required
def me():
return jsonify(serialize_user(g.current_user)), 200

View File

@@ -0,0 +1,158 @@
"""Engagement CRUD endpoints."""
from __future__ import annotations
from datetime import date
from flask import Blueprint, g, jsonify, request
from backend.app.auth import login_required, role_required
from backend.app.extensions import db
from backend.app.models import Engagement, EngagementStatus
from backend.app.serializers import serialize_engagement
engagements_bp = Blueprint("engagements", __name__, url_prefix="/api/engagements")
def _parse_date(value: object) -> date | None:
if not isinstance(value, str):
return None
try:
return date.fromisoformat(value)
except ValueError:
return None
def _parse_status(value: object) -> EngagementStatus | None:
if not isinstance(value, str):
return None
try:
return EngagementStatus(value)
except ValueError:
return None
@engagements_bp.get("")
@login_required
def list_engagements():
items = Engagement.query.order_by(Engagement.id.asc()).all()
return jsonify([serialize_engagement(e) for e in items]), 200
@engagements_bp.post("")
@role_required("admin", "redteam")
def create_engagement():
data = request.get_json(silent=True) or {}
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name is required"}), 400
start_raw = data.get("start_date")
start_date = _parse_date(start_raw) if start_raw else None
if start_date is None:
return jsonify({"error": "start_date is required (YYYY-MM-DD)"}), 400
end_raw = data.get("end_date")
end_date: date | None = None
if end_raw:
end_date = _parse_date(end_raw)
if end_date is None:
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
if end_date < start_date:
return jsonify({"error": "end_date must be >= start_date"}), 400
status = EngagementStatus.PLANNED
if "status" in data and data.get("status") is not None:
parsed = _parse_status(data.get("status"))
if parsed is None:
return (
jsonify({"error": "status must be one of: planned, active, closed"}),
400,
)
status = parsed
engagement = Engagement(
name=name,
description=data.get("description"),
start_date=start_date,
end_date=end_date,
status=status,
created_by_id=g.current_user.id,
)
db.session.add(engagement)
db.session.commit()
return jsonify(serialize_engagement(engagement)), 201
@engagements_bp.get("/<int:engagement_id>")
@login_required
def get_engagement(engagement_id: int):
engagement = db.session.get(Engagement, engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
return jsonify(serialize_engagement(engagement)), 200
@engagements_bp.patch("/<int:engagement_id>")
@role_required("admin", "redteam")
def update_engagement(engagement_id: int):
engagement = db.session.get(Engagement, engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
data = request.get_json(silent=True) or {}
if "name" in data:
name = (data.get("name") or "").strip()
if not name:
return jsonify({"error": "name must not be empty"}), 400
engagement.name = name
if "description" in data:
engagement.description = data.get("description")
new_start = engagement.start_date
if "start_date" in data:
parsed = _parse_date(data.get("start_date"))
if parsed is None:
return jsonify({"error": "start_date must be YYYY-MM-DD"}), 400
new_start = parsed
new_end = engagement.end_date
if "end_date" in data:
if data.get("end_date") in (None, ""):
new_end = None
else:
parsed = _parse_date(data.get("end_date"))
if parsed is None:
return jsonify({"error": "end_date must be YYYY-MM-DD"}), 400
new_end = parsed
if new_end is not None and new_end < new_start:
return jsonify({"error": "end_date must be >= start_date"}), 400
engagement.start_date = new_start
engagement.end_date = new_end
if "status" in data:
parsed_status = _parse_status((data.get("status") or "").strip())
if parsed_status is None:
return (
jsonify({"error": "status must be one of: planned, active, closed"}),
400,
)
engagement.status = parsed_status
db.session.commit()
return jsonify(serialize_engagement(engagement)), 200
@engagements_bp.delete("/<int:engagement_id>")
@role_required("admin", "redteam")
def delete_engagement(engagement_id: int):
engagement = db.session.get(Engagement, engagement_id)
if engagement is None:
return jsonify({"error": "Engagement not found"}), 404
db.session.delete(engagement)
db.session.commit()
return "", 204

106
backend/app/api/users.py Normal file
View File

@@ -0,0 +1,106 @@
"""User management endpoints (admin only)."""
from __future__ import annotations
from flask import Blueprint, current_app, jsonify, request
from backend.app.auth import hash_password, role_required
from backend.app.extensions import db
from backend.app.models import User, UserRole
from backend.app.serializers import serialize_user
users_bp = Blueprint("users", __name__, url_prefix="/api/users")
def _parse_role(value: object) -> UserRole | None:
if not isinstance(value, str):
return None
try:
return UserRole(value)
except ValueError:
return None
@users_bp.get("")
@role_required("admin")
def list_users():
users = User.query.order_by(User.id.asc()).all()
return jsonify([serialize_user(u) for u in users]), 200
@users_bp.post("")
@role_required("admin")
def create_user():
data = request.get_json(silent=True) or {}
username = (data.get("username") or "").strip()
password = data.get("password") or ""
role_raw = (data.get("role") or "").strip()
if not username:
return jsonify({"error": "username is required"}), 400
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
if len(password) < min_len:
return jsonify({"error": f"password must be at least {min_len} characters"}), 400
role = _parse_role(role_raw)
if role is None:
return jsonify({"error": "role must be one of: admin, redteam, soc"}), 400
if User.query.filter_by(username=username).first() is not None:
return jsonify({"error": "username already exists"}), 400
user = User(username=username, password_hash=hash_password(password), role=role)
db.session.add(user)
db.session.commit()
return jsonify(serialize_user(user)), 201
@users_bp.patch("/<int:user_id>")
@role_required("admin")
def update_user(user_id: int):
user = db.session.get(User, user_id)
if user is None:
return jsonify({"error": "User not found"}), 404
data = request.get_json(silent=True) or {}
if "role" in data:
new_role = _parse_role((data.get("role") or "").strip())
if new_role is None:
return jsonify({"error": "role must be one of: admin, redteam, soc"}), 400
# Refuse to demote the last admin.
if user.role == UserRole.ADMIN and new_role != UserRole.ADMIN:
admin_count = User.query.filter_by(role=UserRole.ADMIN).count()
if admin_count <= 1:
return jsonify({"error": "Cannot demote the last admin"}), 409
user.role = new_role
if "password" in data:
password = data.get("password") or ""
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
if len(password) < min_len:
return (
jsonify({"error": f"password must be at least {min_len} characters"}),
400,
)
user.password_hash = hash_password(password)
db.session.commit()
return jsonify(serialize_user(user)), 200
@users_bp.delete("/<int:user_id>")
@role_required("admin")
def delete_user(user_id: int):
user = db.session.get(User, user_id)
if user is None:
return jsonify({"error": "User not found"}), 404
if user.role == UserRole.ADMIN:
admin_count = User.query.filter_by(role=UserRole.ADMIN).count()
if admin_count <= 1:
return jsonify({"error": "Cannot delete the last admin"}), 409
db.session.delete(user)
db.session.commit()
return "", 204

View File

@@ -0,0 +1,13 @@
"""Auth helpers (JWT, hashing, decorators)."""
from backend.app.auth.decorators import login_required, role_required
from backend.app.auth.hashing import hash_password, verify_password
from backend.app.auth.jwt import decode_token, encode_token
__all__ = [
"hash_password",
"verify_password",
"encode_token",
"decode_token",
"login_required",
"role_required",
]

View File

@@ -0,0 +1,67 @@
"""Auth decorators that gate routes on a valid JWT and (optionally) role."""
from __future__ import annotations
from collections.abc import Callable
from functools import wraps
from typing import Any
import jwt
from flask import g, jsonify, request
from backend.app.auth.jwt import decode_token
from backend.app.extensions import db
from backend.app.models import User
def _extract_token() -> str | None:
header = request.headers.get("Authorization", "")
if not header.startswith("Bearer "):
return None
return header.removeprefix("Bearer ").strip() or None
def login_required(fn: Callable[..., Any]) -> Callable[..., Any]:
"""Require a valid JWT. Populates `g.current_user` with the User row."""
@wraps(fn)
def wrapper(*args: Any, **kwargs: Any) -> Any:
token = _extract_token()
if not token:
return jsonify({"error": "Missing or invalid Authorization header"}), 401
try:
payload = decode_token(token)
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token expired"}), 401
except jwt.PyJWTError:
return jsonify({"error": "Invalid token"}), 401
try:
user_id = int(payload["sub"])
except (KeyError, TypeError, ValueError):
return jsonify({"error": "Invalid token payload"}), 401
user = db.session.get(User, user_id)
if user is None:
return jsonify({"error": "User no longer exists"}), 401
g.current_user = user
return fn(*args, **kwargs)
return wrapper
def role_required(*roles: str) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
"""Require the current user to hold one of the given role names."""
def decorator(fn: Callable[..., Any]) -> Callable[..., Any]:
@wraps(fn)
@login_required
def wrapper(*args: Any, **kwargs: Any) -> Any:
user = g.current_user
if user.role.value not in roles:
return jsonify({"error": "Forbidden"}), 403
return fn(*args, **kwargs)
return wrapper
return decorator

View File

@@ -0,0 +1,23 @@
"""Password hashing using argon2."""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError
_hasher = PasswordHasher()
def hash_password(password: str) -> str:
"""Return an argon2 hash of `password`."""
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
"""Return True iff `password` matches `password_hash`."""
try:
return _hasher.verify(password_hash, password)
except VerifyMismatchError:
return False
except Exception:
# Malformed hash or other argon2 error — treat as auth failure.
return False

34
backend/app/auth/jwt.py Normal file
View File

@@ -0,0 +1,34 @@
"""JWT encode/decode helpers."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any
import jwt
from flask import current_app
def encode_token(user_id: int, role: str) -> str:
"""Return a signed JWT for the given user."""
now = datetime.now(UTC)
exp_minutes = current_app.config["JWT_EXP_MINUTES"]
payload: dict[str, Any] = {
"sub": str(user_id),
"role": role,
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=exp_minutes)).timestamp()),
}
return jwt.encode(
payload,
current_app.config["JWT_SECRET"],
algorithm=current_app.config["JWT_ALGORITHM"],
)
def decode_token(token: str) -> dict[str, Any]:
"""Decode + validate a JWT. Raises jwt.PyJWTError on failure."""
return jwt.decode(
token,
current_app.config["JWT_SECRET"],
algorithms=[current_app.config["JWT_ALGORITHM"]],
)

38
backend/app/cli.py Normal file
View File

@@ -0,0 +1,38 @@
"""Flask CLI commands."""
from __future__ import annotations
import sys
import click
from flask import Flask, current_app
from backend.app.auth import hash_password
from backend.app.extensions import db
from backend.app.models import User, UserRole
def register_cli(app: Flask) -> None:
@app.cli.command("create-admin")
@click.argument("username")
@click.argument("password")
def create_admin(username: str, password: str) -> None:
"""Create an admin user. Used to bootstrap the first account."""
min_len = current_app.config["MIN_PASSWORD_LENGTH"]
if len(password) < min_len:
click.echo(
f"Error: password must be at least {min_len} characters.", err=True
)
sys.exit(2)
if User.query.filter_by(username=username).first() is not None:
click.echo(f"Error: username '{username}' already exists.", err=True)
sys.exit(1)
user = User(
username=username,
password_hash=hash_password(password),
role=UserRole.ADMIN,
)
db.session.add(user)
db.session.commit()
click.echo(f"Admin user '{username}' created (id={user.id}).")

35
backend/app/config.py Normal file
View File

@@ -0,0 +1,35 @@
"""Application configuration loaded from environment variables."""
from __future__ import annotations
import os
class Config:
"""Base configuration. Reads from env vars; fails loud on missing secrets."""
SQLALCHEMY_TRACK_MODIFICATIONS = False
JWT_ALGORITHM = "HS256"
JWT_EXP_MINUTES = 60
MIN_PASSWORD_LENGTH = 8
def __init__(self) -> None:
db_path = os.environ.get("MIMIC_DB_PATH", "/data/mimic.sqlite")
self.SQLALCHEMY_DATABASE_URI = f"sqlite:///{db_path}"
jwt_secret = os.environ.get("MIMIC_JWT_SECRET")
if not jwt_secret:
raise RuntimeError(
"MIMIC_JWT_SECRET environment variable is required but not set."
)
self.JWT_SECRET = jwt_secret
class TestConfig(Config):
"""Config for pytest. Uses in-memory SQLite + fixed JWT secret."""
TESTING = True
def __init__(self) -> None:
# Bypass parent's env requirement; tests inject their own secret.
self.SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
self.JWT_SECRET = "test-secret-do-not-use-in-prod"

21
backend/app/errors.py Normal file
View File

@@ -0,0 +1,21 @@
"""Uniform JSON error handlers."""
from __future__ import annotations
from flask import Flask, jsonify
from werkzeug.exceptions import HTTPException
def register_error_handlers(app: Flask) -> None:
@app.errorhandler(HTTPException)
def handle_http_exception(exc: HTTPException):
response = jsonify({"error": exc.description})
response.status_code = exc.code or 500
return response
@app.errorhandler(404)
def handle_404(_exc):
return jsonify({"error": "Not found"}), 404
@app.errorhandler(405)
def handle_405(_exc):
return jsonify({"error": "Method not allowed"}), 405

View File

@@ -0,0 +1,6 @@
"""Shared Flask extension instances."""
from flask_migrate import Migrate
from flask_sqlalchemy import SQLAlchemy
db = SQLAlchemy()
migrate = Migrate()

View File

@@ -0,0 +1,5 @@
"""SQLAlchemy models."""
from backend.app.models.engagement import Engagement, EngagementStatus
from backend.app.models.user import User, UserRole
__all__ = ["User", "UserRole", "Engagement", "EngagementStatus"]

View File

@@ -0,0 +1,39 @@
"""Engagement model."""
from __future__ import annotations
import enum
from datetime import UTC, datetime
from backend.app.extensions import db
class EngagementStatus(str, enum.Enum):
PLANNED = "planned"
ACTIVE = "active"
CLOSED = "closed"
class Engagement(db.Model): # type: ignore[name-defined]
__tablename__ = "engagements"
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(255), nullable=False)
description = db.Column(db.Text, nullable=True)
start_date = db.Column(db.Date, nullable=False)
end_date = db.Column(db.Date, nullable=True)
status = db.Column(
db.Enum(EngagementStatus, name="engagement_status"),
nullable=False,
default=EngagementStatus.PLANNED,
)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)
created_by_id = db.Column(
db.Integer, db.ForeignKey("users.id", ondelete="RESTRICT"), nullable=False
)
created_by = db.relationship("User", backref="engagements", lazy="joined")
def __repr__(self) -> str:
return f"<Engagement {self.id} {self.name!r}>"

View File

@@ -0,0 +1,28 @@
"""User model."""
from __future__ import annotations
import enum
from datetime import UTC, datetime
from backend.app.extensions import db
class UserRole(str, enum.Enum):
ADMIN = "admin"
REDTEAM = "redteam"
SOC = "soc"
class User(db.Model): # type: ignore[name-defined]
__tablename__ = "users"
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(64), unique=True, nullable=False, index=True)
password_hash = db.Column(db.String(255), nullable=False)
role = db.Column(db.Enum(UserRole, name="user_role"), nullable=False)
created_at = db.Column(
db.DateTime, nullable=False, default=lambda: datetime.now(UTC)
)
def __repr__(self) -> str:
return f"<User {self.username} ({self.role.value})>"

View File

@@ -0,0 +1,34 @@
"""JSON serializers for API responses."""
from __future__ import annotations
from typing import Any
from backend.app.models import Engagement, User
def serialize_user(user: User) -> dict[str, Any]:
return {
"id": user.id,
"username": user.username,
"role": user.role.value,
"created_at": user.created_at.isoformat() if user.created_at else None,
}
def serialize_user_brief(user: User) -> dict[str, Any]:
return {"id": user.id, "username": user.username}
def serialize_engagement(engagement: Engagement) -> dict[str, Any]:
return {
"id": engagement.id,
"name": engagement.name,
"description": engagement.description,
"start_date": engagement.start_date.isoformat() if engagement.start_date else None,
"end_date": engagement.end_date.isoformat() if engagement.end_date else None,
"status": engagement.status.value,
"created_at": engagement.created_at.isoformat() if engagement.created_at else None,
"created_by": serialize_user_brief(engagement.created_by) # type: ignore[arg-type]
if engagement.created_by
else None,
}