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:
71
backend/app/__init__.py
Normal file
71
backend/app/__init__.py
Normal 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
|
||||
6
backend/app/api/__init__.py
Normal file
6
backend/app/api/__init__.py
Normal 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
46
backend/app/api/auth.py
Normal 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
|
||||
158
backend/app/api/engagements.py
Normal file
158
backend/app/api/engagements.py
Normal 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
106
backend/app/api/users.py
Normal 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
|
||||
13
backend/app/auth/__init__.py
Normal file
13
backend/app/auth/__init__.py
Normal 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",
|
||||
]
|
||||
67
backend/app/auth/decorators.py
Normal file
67
backend/app/auth/decorators.py
Normal 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
|
||||
23
backend/app/auth/hashing.py
Normal file
23
backend/app/auth/hashing.py
Normal 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
34
backend/app/auth/jwt.py
Normal 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
38
backend/app/cli.py
Normal 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
35
backend/app/config.py
Normal 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
21
backend/app/errors.py
Normal 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
|
||||
6
backend/app/extensions.py
Normal file
6
backend/app/extensions.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""Shared Flask extension instances."""
|
||||
from flask_migrate import Migrate
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
5
backend/app/models/__init__.py
Normal file
5
backend/app/models/__init__.py
Normal 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"]
|
||||
39
backend/app/models/engagement.py
Normal file
39
backend/app/models/engagement.py
Normal 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}>"
|
||||
28
backend/app/models/user.py
Normal file
28
backend/app/models/user.py
Normal 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})>"
|
||||
34
backend/app/serializers.py
Normal file
34
backend/app/serializers.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user