Remove legacy bridge and expose auth session endpoint

This commit is contained in:
2025-10-22 20:18:09 -06:00
parent da4cb501e0
commit e1e63ec346
7 changed files with 185 additions and 212 deletions

View File

@@ -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,

View File

@@ -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)

View File

@@ -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")

View File

@@ -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"]

View File

@@ -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)

View 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"}

View File

@@ -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"