mirror of
				https://github.com/bunny-lab-io/Borealis.git
				synced 2025-10-26 15:41:58 -06:00 
			
		
		
		
	Remove legacy bridge and expose auth session endpoint
This commit is contained in:
		| @@ -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, | ||||
|   | ||||
| @@ -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) | ||||
|  | ||||
|  | ||||
|   | ||||
| @@ -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") | ||||
|   | ||||
| @@ -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"] | ||||
|   | ||||
| @@ -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) | ||||
|   | ||||
							
								
								
									
										120
									
								
								Data/Engine/tests/test_http_auth.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										120
									
								
								Data/Engine/tests/test_http_auth.py
									
									
									
									
									
										Normal file
									
								
							| @@ -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("<html></html>", 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"} | ||||
| @@ -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" | ||||
		Reference in New Issue
	
	Block a user