From e1e63ec34698cad6d267efc4a1da8f4e2ca1354e Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Wed, 22 Oct 2025 20:18:09 -0600 Subject: [PATCH] Remove legacy bridge and expose auth session endpoint --- Data/Engine/bootstrapper.py | 14 +- Data/Engine/interfaces/http/__init__.py | 6 +- Data/Engine/interfaces/http/auth.py | 30 ++++ Data/Engine/server.py | 138 +----------------- .../services/auth/operator_auth_service.py | 29 +++- Data/Engine/tests/test_http_auth.py | 120 +++++++++++++++ .../Engine/tests/test_server_legacy_bridge.py | 60 -------- 7 files changed, 185 insertions(+), 212 deletions(-) create mode 100644 Data/Engine/tests/test_http_auth.py delete mode 100644 Data/Engine/tests/test_server_legacy_bridge.py diff --git a/Data/Engine/bootstrapper.py b/Data/Engine/bootstrapper.py index 46d0194..c613504 100644 --- a/Data/Engine/bootstrapper.py +++ b/Data/Engine/bootstrapper.py @@ -18,7 +18,7 @@ from .interfaces import ( from .interfaces.eventlet_compat import apply_eventlet_patches from .repositories.sqlite import connection as sqlite_connection from .repositories.sqlite import migrations as sqlite_migrations -from .server import attach_legacy_bridge, create_app +from .server import create_app from .services.container import build_service_container from .services.crypto.certificates import ensure_certificate @@ -71,19 +71,13 @@ def bootstrap() -> EngineRuntime: logger.info("default-admin-ensured") app = create_app(settings, db_factory=db_factory) - attach_legacy_bridge(app, settings, logger=logger) services = build_service_container(settings, db_factory=db_factory, logger=logger.getChild("services")) app.extensions["engine_services"] = services register_http_interfaces(app, services) - legacy_active = bool(app.config.get("ENGINE_LEGACY_BRIDGE_ACTIVE")) - if legacy_active: - socketio = None - logger.info("legacy-ws-deferred") - else: - socketio = create_socket_server(app, settings.socketio) - register_ws_interfaces(socketio, services) - services.scheduler_service.start(socketio) + socketio = create_socket_server(app, settings.socketio) + register_ws_interfaces(socketio, services) + services.scheduler_service.start(socketio) logger.info("bootstrap-complete") return EngineRuntime( app=app, diff --git a/Data/Engine/interfaces/http/__init__.py b/Data/Engine/interfaces/http/__init__.py index c8a568a..e388b81 100644 --- a/Data/Engine/interfaces/http/__init__.py +++ b/Data/Engine/interfaces/http/__init__.py @@ -26,11 +26,7 @@ def register_http_interfaces(app: Flask, services: EngineServiceContainer) -> No The implementation is intentionally minimal for the initial scaffolding. """ - registrars = list(_REGISTRARS) - if app.config.get("ENGINE_LEGACY_BRIDGE_ACTIVE"): - registrars = [r for r in registrars if r is not job_management.register] - - for registrar in registrars: + for registrar in _REGISTRARS: registrar(app, services) diff --git a/Data/Engine/interfaces/http/auth.py b/Data/Engine/interfaces/http/auth.py index d91d9c7..1ba5bdb 100644 --- a/Data/Engine/interfaces/http/auth.py +++ b/Data/Engine/interfaces/http/auth.py @@ -90,6 +90,36 @@ def register(app: Flask, services: EngineServiceContainer) -> None: _set_auth_cookie(response, "", expires=0) return response + @bp.route("/api/auth/me", methods=["GET"]) + def me() -> Any: + service = _service(services) + + account = None + username = session.get("username") + if isinstance(username, str) and username: + account = service.fetch_account(username) + + if account is None: + token = request.cookies.get("borealis_auth", "") + if not token: + auth_header = request.headers.get("Authorization", "") + if auth_header.lower().startswith("bearer "): + token = auth_header.split(None, 1)[1] + account = service.resolve_token(token) + if account is not None: + session["username"] = account.username + session["role"] = account.role or "User" + + if account is None: + return jsonify({"error": "not_authenticated"}), 401 + + payload = { + "username": account.username, + "display_name": account.display_name or account.username, + "role": account.role, + } + return jsonify(payload) + @bp.route("/api/auth/mfa/verify", methods=["POST"]) def verify_mfa() -> Any: pending = session.get("mfa_pending") diff --git a/Data/Engine/server.py b/Data/Engine/server.py index 3cb7376..77fb8ea 100644 --- a/Data/Engine/server.py +++ b/Data/Engine/server.py @@ -2,11 +2,8 @@ from __future__ import annotations -import importlib -import logging -import os from pathlib import Path -from typing import Any, Iterable, Optional +from typing import Optional from flask import Flask, request, send_from_directory from flask_cors import CORS @@ -103,135 +100,4 @@ def create_app( return app -def attach_legacy_bridge( - app: Flask, - settings: EngineSettings, - *, - logger: Optional[logging.Logger] = None, -) -> None: - """Attach the legacy Flask application as a fallback dispatcher. - - Borealis ships a mature API surface inside ``Data/Server/server.py``. The - Engine will eventually supersede it, but during the migration the React - frontend still expects the historical endpoints to exist. This helper - attempts to load the legacy application and wires it as a fallback WSGI - dispatcher so any route the Engine does not yet implement transparently - defers to the proven implementation. - """ - - log = logger or logging.getLogger("borealis.engine.legacy") - - if not _legacy_bridge_enabled(): - log.info("legacy-bridge-disabled") - return - - legacy = _load_legacy_app(settings, app, log) - if legacy is None: - log.warning("legacy-bridge-unavailable") - return - - app.config["ENGINE_LEGACY_BRIDGE_ACTIVE"] = True - app.wsgi_app = _FallbackDispatcher(app.wsgi_app, legacy.wsgi_app) # type: ignore[assignment] - app.extensions["legacy_flask_app"] = legacy - log.info("legacy-bridge-active") - - -def _legacy_bridge_enabled() -> bool: - raw = os.getenv("BOREALIS_ENGINE_ENABLE_LEGACY_BRIDGE", "1") - return raw.strip().lower() in {"1", "true", "yes", "on"} - - -def _load_legacy_app( - settings: EngineSettings, - engine_app: Flask, - logger: logging.Logger, -) -> Optional[Flask]: - try: - legacy_module = importlib.import_module("Data.Server.server") - except Exception as exc: # pragma: no cover - defensive - logger.exception("legacy-import-failed", exc_info=exc) - return None - - legacy_app = getattr(legacy_module, "app", None) - if not isinstance(legacy_app, Flask): - logger.error("legacy-app-missing") - return None - - # Align runtime configuration so both applications share database and - # session state. - try: - setattr(legacy_module, "DB_PATH", str(settings.database_path)) - except Exception: # pragma: no cover - defensive - logger.warning("legacy-db-path-sync-failed", extra={"path": str(settings.database_path)}) - - _synchronise_session_config(engine_app, legacy_app) - - return legacy_app - - -def _synchronise_session_config(engine_app: Flask, legacy_app: Flask) -> None: - legacy_app.secret_key = engine_app.config.get("SECRET_KEY", legacy_app.secret_key) - for key in ( - "SESSION_COOKIE_HTTPONLY", - "SESSION_COOKIE_SECURE", - "SESSION_COOKIE_SAMESITE", - "SESSION_COOKIE_DOMAIN", - ): - value = engine_app.config.get(key) - if value is not None: - legacy_app.config[key] = value - - -class _FallbackDispatcher: - """WSGI dispatcher that retries a secondary app when the primary 404s.""" - - __slots__ = ("_primary", "_fallback", "_retry_statuses") - - def __init__( - self, - primary: Any, - fallback: Any, - *, - retry_statuses: Iterable[int] = (404,), - ) -> None: - self._primary = primary - self._fallback = fallback - self._retry_statuses = {int(status) for status in retry_statuses} - - def __call__(self, environ: dict[str, Any], start_response: Any) -> Iterable[bytes]: - captured_body: list[bytes] = [] - captured_status: dict[str, Any] = {} - - def _capture_start_response(status: str, headers: list[tuple[str, str]], exc_info: Any = None): - captured_status["status"] = status - captured_status["headers"] = headers - captured_status["exc_info"] = exc_info - - def _write(data: bytes) -> None: - captured_body.append(data) - - return _write - - primary_iterable = self._primary(environ, _capture_start_response) - try: - for chunk in primary_iterable: - captured_body.append(chunk) - finally: - close = getattr(primary_iterable, "close", None) - if callable(close): - close() - - status_line = str(captured_status.get("status") or "500 Internal Server Error") - try: - status_code = int(status_line.split()[0]) - except Exception: # pragma: no cover - defensive - status_code = 500 - - if status_code not in self._retry_statuses: - start_response(status_line, captured_status.get("headers", []), captured_status.get("exc_info")) - return captured_body - - return self._fallback(environ, start_response) - - -__all__ = ["attach_legacy_bridge", "create_app"] +__all__ = ["create_app"] diff --git a/Data/Engine/services/auth/operator_auth_service.py b/Data/Engine/services/auth/operator_auth_service.py index d3c1163..d9256bf 100644 --- a/Data/Engine/services/auth/operator_auth_service.py +++ b/Data/Engine/services/auth/operator_auth_service.py @@ -20,7 +20,7 @@ try: # pragma: no cover - optional dependency except Exception: # pragma: no cover - gracefully degrade when unavailable qrcode = None # type: ignore -from itsdangerous import URLSafeTimedSerializer +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer from Data.Engine.builders.operator_auth import ( OperatorLoginRequest, @@ -119,6 +119,33 @@ class OperatorAuthService: payload = {"u": username, "r": role or "User", "ts": int(time.time())} return serializer.dumps(payload) + def resolve_token(self, token: str, *, max_age: int = 30 * 24 * 3600) -> Optional[OperatorAccount]: + """Return the account associated with *token* if it is valid.""" + + token = (token or "").strip() + if not token: + return None + + serializer = self._token_serializer() + try: + payload = serializer.loads(token, max_age=max_age) + except (BadSignature, SignatureExpired): + return None + + username = str(payload.get("u") or "").strip() + if not username: + return None + + return self._repository.fetch_by_username(username) + + def fetch_account(self, username: str) -> Optional[OperatorAccount]: + """Return the operator account for *username* if it exists.""" + + username = (username or "").strip() + if not username: + return None + return self._repository.fetch_by_username(username) + def _finalize_login(self, account: OperatorAccount) -> OperatorLoginSuccess: now = int(time.time()) self._repository.update_last_login(account.username, now) diff --git a/Data/Engine/tests/test_http_auth.py b/Data/Engine/tests/test_http_auth.py new file mode 100644 index 0000000..b5fc39c --- /dev/null +++ b/Data/Engine/tests/test_http_auth.py @@ -0,0 +1,120 @@ +import hashlib +from pathlib import Path + +import pytest + +pytest.importorskip("flask") + +from Data.Engine.config.environment import ( + DatabaseSettings, + EngineSettings, + FlaskSettings, + GitHubSettings, + ServerSettings, + SocketIOSettings, +) +from Data.Engine.interfaces.http import register_http_interfaces +from Data.Engine.repositories.sqlite import connection as sqlite_connection +from Data.Engine.repositories.sqlite import migrations as sqlite_migrations +from Data.Engine.server import create_app +from Data.Engine.services.container import build_service_container + + +@pytest.fixture() +def engine_settings(tmp_path: Path) -> EngineSettings: + project_root = tmp_path + static_root = project_root / "static" + static_root.mkdir() + (static_root / "index.html").write_text("", encoding="utf-8") + + database_path = project_root / "database.db" + + return EngineSettings( + project_root=project_root, + debug=False, + database=DatabaseSettings(path=database_path, apply_migrations=False), + flask=FlaskSettings( + secret_key="test-key", + static_root=static_root, + cors_allowed_origins=("https://localhost",), + ), + socketio=SocketIOSettings(cors_allowed_origins=("https://localhost",)), + server=ServerSettings(host="127.0.0.1", port=5000), + github=GitHubSettings( + default_repo="owner/repo", + default_branch="main", + refresh_interval_seconds=60, + cache_root=project_root / "cache", + ), + ) + + +@pytest.fixture() +def prepared_app(engine_settings: EngineSettings): + settings = engine_settings + settings.github.cache_root.mkdir(exist_ok=True, parents=True) + + db_factory = sqlite_connection.connection_factory(settings.database.path) + with sqlite_connection.connection_scope(settings.database.path) as conn: + sqlite_migrations.apply_all(conn) + + app = create_app(settings, db_factory=db_factory) + services = build_service_container(settings, db_factory=db_factory) + app.extensions["engine_services"] = services + register_http_interfaces(app, services) + app.config.update(TESTING=True) + return app + + +def _login(client) -> dict: + payload = { + "username": "admin", + "password_sha512": hashlib.sha512("Password".encode()).hexdigest(), + } + resp = client.post("/api/auth/login", json=payload) + assert resp.status_code == 200 + data = resp.get_json() + assert isinstance(data, dict) + return data + + +def test_auth_me_returns_session_user(prepared_app): + client = prepared_app.test_client() + + _login(client) + resp = client.get("/api/auth/me") + assert resp.status_code == 200 + body = resp.get_json() + assert body == { + "username": "admin", + "display_name": "admin", + "role": "Admin", + } + + +def test_auth_me_uses_token_when_session_missing(prepared_app): + client = prepared_app.test_client() + login_data = _login(client) + token = login_data.get("token") + assert token + + # New client without session + other_client = prepared_app.test_client() + other_client.set_cookie(server_name="localhost", key="borealis_auth", value=token) + + resp = other_client.get("/api/auth/me") + assert resp.status_code == 200 + body = resp.get_json() + assert body == { + "username": "admin", + "display_name": "admin", + "role": "Admin", + } + + +def test_auth_me_requires_authentication(prepared_app): + client = prepared_app.test_client() + resp = client.get("/api/auth/me") + assert resp.status_code == 401 + body = resp.get_json() + assert body == {"error": "not_authenticated"} diff --git a/Data/Engine/tests/test_server_legacy_bridge.py b/Data/Engine/tests/test_server_legacy_bridge.py deleted file mode 100644 index 1c3f77f..0000000 --- a/Data/Engine/tests/test_server_legacy_bridge.py +++ /dev/null @@ -1,60 +0,0 @@ -from __future__ import annotations - -from typing import Callable, Iterable - -import pytest - -pytest.importorskip("flask") - -from Data.Engine.server import _FallbackDispatcher - - -def _wsgi_app(status: str, body: bytes) -> Callable: - def _app(environ, start_response): # type: ignore[override] - start_response(status, [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))]) - return [body] - - return _app - - -def _invoke(app: Callable, path: str = "/") -> tuple[str, bytes]: - status_holder: dict[str, str] = {} - body_parts: list[bytes] = [] - - def _start_response(status: str, headers: Iterable[tuple[str, str]], exc_info=None): # type: ignore[override] - status_holder["status"] = status - return body_parts.append - - environ = {"PATH_INFO": path, "REQUEST_METHOD": "GET", "wsgi.input": None} - result = app(environ, _start_response) - try: - for chunk in result: - body_parts.append(chunk) - finally: - close = getattr(result, "close", None) - if callable(close): - close() - - return status_holder.get("status", ""), b"".join(body_parts) - - -def test_fallback_dispatcher_primary_wins() -> None: - primary = _wsgi_app("200 OK", b"engine") - fallback = _wsgi_app("200 OK", b"legacy") - dispatcher = _FallbackDispatcher(primary, fallback) - - status, body = _invoke(dispatcher) - - assert status == "200 OK" - assert body == b"engine" - - -def test_fallback_dispatcher_uses_fallback_on_404() -> None: - primary = _wsgi_app("404 Not Found", b"missing") - fallback = _wsgi_app("200 OK", b"legacy") - dispatcher = _FallbackDispatcher(primary, fallback) - - status, body = _invoke(dispatcher) - - assert status == "200 OK" - assert body == b"legacy"