Files
mimic-big/backend/src/mimic/app.py

136 lines
4.8 KiB
Python
Raw Normal View History

"""Flask application factory."""
from __future__ import annotations
from datetime import timedelta
fix(backend): JSON error envelope for every HTTPException + strict_slashes=False Two issues spotted by ux-frontend consuming docs/api.md against the actual code path: 1. `flask.abort(...)` returned the Werkzeug HTML error page for 400/403/404/ 422/etc. — only the 401 paths going through `api_error()` and the Flask-Login `unauthorized_handler` honoured the `{error, message}` envelope the contract promised. The frontend's `ApiClientError.body` parser was forced to fall back to a raw string, and the 422 case could not surface Pydantic per-field errors. Fix: register `@app.errorhandler(HTTPException)` that serialises every `HTTPException` to the same JSON envelope. 422s gain a `details: [...]` field holding the Pydantic `errors()` list (`loc` / `msg` / `type`), matching the shape now documented in `docs/api.md`. A `_HTTP_ERROR_CODES` map maps statuses to stable snake_case codes (`bad_request`, `not_found`, `method_not_allowed`, `validation_error`, `forbidden`, `internal_error`, ...). Unknown statuses fall back to `http_error`. `description` is `cast(object, ...)` because the Werkzeug stub pins it to `str | None` while `flask.abort(..., description=<list>)` is the officially supported way to smuggle a Pydantic errors list to the handler. 2. `@bp.get("")` on the engagements blueprint produced `/api/v1/engagements` (no slash). Hitting it with a trailing slash issued a 308 redirect, and some browsers drop the session cookie across that hop. Fix: `app.url_map.strict_slashes = False`. Both forms now match the same handler without redirect. 5 new integration tests cover the new envelope shape (422 with details, unknown 404, malformed-JSON 400) and the dual-slash matching. `docs/api.md` rewritten to reflect the table of stable codes, the `details` shape, and the no-trailing-slash convention. `CHANGELOG.md` gains a follow-up entry. Verification: ruff check / mypy --strict / pytest tests/unit all green (61 unit + 5 new integration).
2026-05-23 04:33:23 +02:00
from typing import cast
from flask import Flask, jsonify
from flask.typing import ResponseReturnValue
fix(backend): JSON error envelope for every HTTPException + strict_slashes=False Two issues spotted by ux-frontend consuming docs/api.md against the actual code path: 1. `flask.abort(...)` returned the Werkzeug HTML error page for 400/403/404/ 422/etc. — only the 401 paths going through `api_error()` and the Flask-Login `unauthorized_handler` honoured the `{error, message}` envelope the contract promised. The frontend's `ApiClientError.body` parser was forced to fall back to a raw string, and the 422 case could not surface Pydantic per-field errors. Fix: register `@app.errorhandler(HTTPException)` that serialises every `HTTPException` to the same JSON envelope. 422s gain a `details: [...]` field holding the Pydantic `errors()` list (`loc` / `msg` / `type`), matching the shape now documented in `docs/api.md`. A `_HTTP_ERROR_CODES` map maps statuses to stable snake_case codes (`bad_request`, `not_found`, `method_not_allowed`, `validation_error`, `forbidden`, `internal_error`, ...). Unknown statuses fall back to `http_error`. `description` is `cast(object, ...)` because the Werkzeug stub pins it to `str | None` while `flask.abort(..., description=<list>)` is the officially supported way to smuggle a Pydantic errors list to the handler. 2. `@bp.get("")` on the engagements blueprint produced `/api/v1/engagements` (no slash). Hitting it with a trailing slash issued a 308 redirect, and some browsers drop the session cookie across that hop. Fix: `app.url_map.strict_slashes = False`. Both forms now match the same handler without redirect. 5 new integration tests cover the new envelope shape (422 with details, unknown 404, malformed-JSON 400) and the dual-slash matching. `docs/api.md` rewritten to reflect the table of stable codes, the `details` shape, and the no-trailing-slash convention. `CHANGELOG.md` gains a follow-up entry. Verification: ruff check / mypy --strict / pytest tests/unit all green (61 unit + 5 new integration).
2026-05-23 04:33:23 +02:00
from werkzeug.exceptions import HTTPException
from mimic.api import register_blueprints
from mimic.auth.identity import load_user
from mimic.config import Settings, get_settings
from mimic.extensions import db, login_manager, migrate, socketio
from mimic.logging import configure_logging
fix(backend): JSON error envelope for every HTTPException + strict_slashes=False Two issues spotted by ux-frontend consuming docs/api.md against the actual code path: 1. `flask.abort(...)` returned the Werkzeug HTML error page for 400/403/404/ 422/etc. — only the 401 paths going through `api_error()` and the Flask-Login `unauthorized_handler` honoured the `{error, message}` envelope the contract promised. The frontend's `ApiClientError.body` parser was forced to fall back to a raw string, and the 422 case could not surface Pydantic per-field errors. Fix: register `@app.errorhandler(HTTPException)` that serialises every `HTTPException` to the same JSON envelope. 422s gain a `details: [...]` field holding the Pydantic `errors()` list (`loc` / `msg` / `type`), matching the shape now documented in `docs/api.md`. A `_HTTP_ERROR_CODES` map maps statuses to stable snake_case codes (`bad_request`, `not_found`, `method_not_allowed`, `validation_error`, `forbidden`, `internal_error`, ...). Unknown statuses fall back to `http_error`. `description` is `cast(object, ...)` because the Werkzeug stub pins it to `str | None` while `flask.abort(..., description=<list>)` is the officially supported way to smuggle a Pydantic errors list to the handler. 2. `@bp.get("")` on the engagements blueprint produced `/api/v1/engagements` (no slash). Hitting it with a trailing slash issued a 308 redirect, and some browsers drop the session cookie across that hop. Fix: `app.url_map.strict_slashes = False`. Both forms now match the same handler without redirect. 5 new integration tests cover the new envelope shape (422 with details, unknown 404, malformed-JSON 400) and the dual-slash matching. `docs/api.md` rewritten to reflect the table of stable codes, the `details` shape, and the no-trailing-slash convention. `CHANGELOG.md` gains a follow-up entry. Verification: ruff check / mypy --strict / pytest tests/unit all green (61 unit + 5 new integration).
2026-05-23 04:33:23 +02:00
# 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__)
fix(backend): JSON error envelope for every HTTPException + strict_slashes=False Two issues spotted by ux-frontend consuming docs/api.md against the actual code path: 1. `flask.abort(...)` returned the Werkzeug HTML error page for 400/403/404/ 422/etc. — only the 401 paths going through `api_error()` and the Flask-Login `unauthorized_handler` honoured the `{error, message}` envelope the contract promised. The frontend's `ApiClientError.body` parser was forced to fall back to a raw string, and the 422 case could not surface Pydantic per-field errors. Fix: register `@app.errorhandler(HTTPException)` that serialises every `HTTPException` to the same JSON envelope. 422s gain a `details: [...]` field holding the Pydantic `errors()` list (`loc` / `msg` / `type`), matching the shape now documented in `docs/api.md`. A `_HTTP_ERROR_CODES` map maps statuses to stable snake_case codes (`bad_request`, `not_found`, `method_not_allowed`, `validation_error`, `forbidden`, `internal_error`, ...). Unknown statuses fall back to `http_error`. `description` is `cast(object, ...)` because the Werkzeug stub pins it to `str | None` while `flask.abort(..., description=<list>)` is the officially supported way to smuggle a Pydantic errors list to the handler. 2. `@bp.get("")` on the engagements blueprint produced `/api/v1/engagements` (no slash). Hitting it with a trailing slash issued a 308 redirect, and some browsers drop the session cookie across that hop. Fix: `app.url_map.strict_slashes = False`. Both forms now match the same handler without redirect. 5 new integration tests cover the new envelope shape (422 with details, unknown 404, malformed-JSON 400) and the dual-slash matching. `docs/api.md` rewritten to reflect the table of stable codes, the `details` shape, and the no-trailing-slash convention. `CHANGELOG.md` gains a follow-up entry. Verification: ruff check / mypy --strict / pytest tests/unit all green (61 unit + 5 new integration).
2026-05-23 04:33:23 +02:00
# `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,
SQLALCHEMY_TRACK_MODIFICATIONS=False,
SESSION_COOKIE_SECURE=settings.session_cookie_secure,
SESSION_COOKIE_SAMESITE=settings.session_cookie_samesite,
SESSION_COOKIE_HTTPONLY=True,
PERMANENT_SESSION_LIFETIME=timedelta(minutes=settings.session_lifetime_minutes),
MIMIC_SETTINGS=settings,
)
db.init_app(app)
migrate.init_app(app, db, directory="src/mimic/db/migrations")
login_manager.init_app(app)
chore(backend): mypy strict clean + ruff format pass Pre-merge sanity per devops checklist (ruff format --check, mypy --strict). Type fixes: - ORM models: `Mapped[dict]` → `Mapped[dict[str, Any]]` (audit, scenario, run, report, ttp, detection.artifact_files_json). Equivalent on Pydantic DTOs (TtpBase.params_schema_json, ScenarioStepBase.params_override_json). - Rename `TtpRead.current_version` → `TtpRead.version` to mirror the ORM column (which itself was renamed in D-009 cleanup). - Flask blueprints: add `-> ResponseReturnValue` to every view, plus typed UUID params on `_validate_step_consistency`. - `templating/filters.py`: rewrite the conditional re2 import so mypy can narrow the union (`ModuleType | None`); the runtime branch on `_re2 is not None` removes the unused-ignore that was triggered by warn_unused_ignores. - `pyproject.toml`: add `flask_login.*` and `pythonjsonlogger.*` to the `[[tool.mypy.overrides]]` `ignore_missing_imports` list (both ship without typed marker). - Misc: drop stale `# type: ignore` comments (`app.py:36`, `rbac/decorators.py:35`) flagged by `warn_unused_ignores`. Keep `logging.JsonFormatter` ignore because the symbol exists at runtime but is not re-exported through the typed surface. Formatting: - `ruff format` applied (15 files normalized; line-length unchanged at 100). Verification on this commit: - `ruff check` → All checks passed. - `ruff format --check` → 68 files already formatted. - `mypy --strict src` → Success: no issues found in 54 source files. - `pytest tests/unit` → 49 passed.
2026-05-22 05:10:51 +02:00
login_manager.user_loader(load_user)
feat(backend): wire auth endpoints + dev CORS (sprint 1) Three login endpoints under /api/v1/auth/ + dev-only CORS so the Vite frontend can drive the session cookie. - POST /login validates local credentials and sets a Flask session cookie. Returns the CurrentUser shape on 200 (user_id, username=email, display_name, role, permissions, groups). Uniform 401 invalid_credentials on bad password or unknown user; a bcrypt round against a dummy hash runs even on unknown users so the request timing does not enumerate accounts. Audits an auth.login row and bumps user.last_login_at. - POST /logout (login_required) clears the session, returns 204, audits an auth.logout row. - GET /me returns the current principal or 401 not_authenticated. Used by the frontend at boot to rehydrate state. Side wiring: - LoginManager.unauthorized_handler emits the same {error, message} JSON envelope so @login_required 401s match the rest of the API surface. - api/_helpers gains `serialize_current_user(AuthUser) -> CurrentUser` and `api_error(code, message, status)` — used by the auth blueprint and available to follow-up endpoints. - AuthUser carries display_name + user_type now; identity.load_user routes through a new `authuser_from_orm()` helper that the login endpoint also uses so /login and the user_loader produce identical shapes. - Dev-only CORS via flask-cors on /api/*, gated on MIMIC_ENV=development AND MIMIC_CORS_ORIGINS non-empty. Prod keeps same-origin (reverse proxy fronts the SPA + API). - LoginRequest + CurrentUser DTOs added to mimic.schemas. No frontend-visible change to engagements (sprint-0 already shipped created_by_id, audit log, F11 scope).
2026-05-23 04:21:44 +02:00
@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,
)
fix(backend): JSON error envelope for every HTTPException + strict_slashes=False Two issues spotted by ux-frontend consuming docs/api.md against the actual code path: 1. `flask.abort(...)` returned the Werkzeug HTML error page for 400/403/404/ 422/etc. — only the 401 paths going through `api_error()` and the Flask-Login `unauthorized_handler` honoured the `{error, message}` envelope the contract promised. The frontend's `ApiClientError.body` parser was forced to fall back to a raw string, and the 422 case could not surface Pydantic per-field errors. Fix: register `@app.errorhandler(HTTPException)` that serialises every `HTTPException` to the same JSON envelope. 422s gain a `details: [...]` field holding the Pydantic `errors()` list (`loc` / `msg` / `type`), matching the shape now documented in `docs/api.md`. A `_HTTP_ERROR_CODES` map maps statuses to stable snake_case codes (`bad_request`, `not_found`, `method_not_allowed`, `validation_error`, `forbidden`, `internal_error`, ...). Unknown statuses fall back to `http_error`. `description` is `cast(object, ...)` because the Werkzeug stub pins it to `str | None` while `flask.abort(..., description=<list>)` is the officially supported way to smuggle a Pydantic errors list to the handler. 2. `@bp.get("")` on the engagements blueprint produced `/api/v1/engagements` (no slash). Hitting it with a trailing slash issued a 308 redirect, and some browsers drop the session cookie across that hop. Fix: `app.url_map.strict_slashes = False`. Both forms now match the same handler without redirect. 5 new integration tests cover the new envelope shape (422 with details, unknown 404, malformed-JSON 400) and the dual-slash matching. `docs/api.md` rewritten to reflect the table of stable codes, the `details` shape, and the no-trailing-slash convention. `CHANGELOG.md` gains a follow-up entry. Verification: ruff check / mypy --strict / pytest tests/unit all green (61 unit + 5 new integration).
2026-05-23 04:33:23 +02:00
@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",
)
feat(backend): wire auth endpoints + dev CORS (sprint 1) Three login endpoints under /api/v1/auth/ + dev-only CORS so the Vite frontend can drive the session cookie. - POST /login validates local credentials and sets a Flask session cookie. Returns the CurrentUser shape on 200 (user_id, username=email, display_name, role, permissions, groups). Uniform 401 invalid_credentials on bad password or unknown user; a bcrypt round against a dummy hash runs even on unknown users so the request timing does not enumerate accounts. Audits an auth.login row and bumps user.last_login_at. - POST /logout (login_required) clears the session, returns 204, audits an auth.logout row. - GET /me returns the current principal or 401 not_authenticated. Used by the frontend at boot to rehydrate state. Side wiring: - LoginManager.unauthorized_handler emits the same {error, message} JSON envelope so @login_required 401s match the rest of the API surface. - api/_helpers gains `serialize_current_user(AuthUser) -> CurrentUser` and `api_error(code, message, status)` — used by the auth blueprint and available to follow-up endpoints. - AuthUser carries display_name + user_type now; identity.load_user routes through a new `authuser_from_orm()` helper that the login endpoint also uses so /login and the user_loader produce identical shapes. - Dev-only CORS via flask-cors on /api/*, gated on MIMIC_ENV=development AND MIMIC_CORS_ORIGINS non-empty. Prod keeps same-origin (reverse proxy fronts the SPA + API). - LoginRequest + CurrentUser DTOs added to mimic.schemas. No frontend-visible change to engagements (sprint-0 already shipped created_by_id, audit log, F11 scope).
2026-05-23 04:21:44 +02:00
_enable_cors_in_dev(app, settings)
register_blueprints(app)
@app.get("/healthz")
def healthz() -> ResponseReturnValue:
return jsonify(status="ok"), 200
return app
feat(backend): wire auth endpoints + dev CORS (sprint 1) Three login endpoints under /api/v1/auth/ + dev-only CORS so the Vite frontend can drive the session cookie. - POST /login validates local credentials and sets a Flask session cookie. Returns the CurrentUser shape on 200 (user_id, username=email, display_name, role, permissions, groups). Uniform 401 invalid_credentials on bad password or unknown user; a bcrypt round against a dummy hash runs even on unknown users so the request timing does not enumerate accounts. Audits an auth.login row and bumps user.last_login_at. - POST /logout (login_required) clears the session, returns 204, audits an auth.logout row. - GET /me returns the current principal or 401 not_authenticated. Used by the frontend at boot to rehydrate state. Side wiring: - LoginManager.unauthorized_handler emits the same {error, message} JSON envelope so @login_required 401s match the rest of the API surface. - api/_helpers gains `serialize_current_user(AuthUser) -> CurrentUser` and `api_error(code, message, status)` — used by the auth blueprint and available to follow-up endpoints. - AuthUser carries display_name + user_type now; identity.load_user routes through a new `authuser_from_orm()` helper that the login endpoint also uses so /login and the user_loader produce identical shapes. - Dev-only CORS via flask-cors on /api/*, gated on MIMIC_ENV=development AND MIMIC_CORS_ORIGINS non-empty. Prod keeps same-origin (reverse proxy fronts the SPA + API). - LoginRequest + CurrentUser DTOs added to mimic.schemas. No frontend-visible change to engagements (sprint-0 already shipped created_by_id, audit log, F11 scope).
2026-05-23 04:21:44 +02:00
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,
)