From 4a34752790d54ff3cf2ebd0307a0294f9c59508e Mon Sep 17 00:00:00 2001 From: Nicole Rappe Date: Sun, 26 Oct 2025 01:20:58 -0600 Subject: [PATCH] Implement Stage 3 Engine API adapters --- Data/Engine/CODE_MIGRATION_TRACKER.md | 10 +- Data/Engine/config.py | 23 ++- Data/Engine/server.py | 4 +- Data/Engine/services/API/__init__.py | 205 ++++++++++++++++++++++++-- 4 files changed, 226 insertions(+), 16 deletions(-) diff --git a/Data/Engine/CODE_MIGRATION_TRACKER.md b/Data/Engine/CODE_MIGRATION_TRACKER.md index 34e3bd9..e202fb3 100644 --- a/Data/Engine/CODE_MIGRATION_TRACKER.md +++ b/Data/Engine/CODE_MIGRATION_TRACKER.md @@ -18,10 +18,10 @@ Lastly, everytime that you complete a stage, you will create a pull request name - [x] Verify context parity between Engine and legacy startup. - [x] Initialize logging to Logs/Server/server.log when Engine mode is active. - [x] Document Engine launch paths and configuration requirements in module docstrings. -- [ ] **Stage 3 — Introduce API blueprints and service adapters** - - [ ] Create domain-focused API blueprints and register_api entry point. - - [ ] Mirror route behaviour from the legacy server via service adapters. - - [ ] Add configuration toggles for enabling API groups incrementally. +- [x] **Stage 3 — Introduce API blueprints and service adapters** + - [x] Create domain-focused API blueprints and register_api entry point. + - [x] Mirror route behaviour from the legacy server via service adapters. + - [x] Add configuration toggles for enabling API groups incrementally. - [ ] **Stage 4 — Build unit and smoke tests for Engine APIs** - [ ] Add pytest modules under Data/Engine/Unit_Tests exercising API blueprints. - [ ] Provide fixtures that mirror the legacy SQLite schema and seed data. @@ -43,5 +43,5 @@ Lastly, everytime that you complete a stage, you will create a pull request name - [ ] Update legacy server to consume Engine WebSocket registration. ## Current Status -- **Stage:** Stage 2 — Port configuration and dependency loading into the Engine factory (completed) +- **Stage:** Stage 3 — Introduce API blueprints and service adapters (completed) - **Active Task:** Awaiting next stage instructions. diff --git a/Data/Engine/config.py b/Data/Engine/config.py index afd52d6..6c30a70 100644 --- a/Data/Engine/config.py +++ b/Data/Engine/config.py @@ -37,7 +37,7 @@ import os from dataclasses import asdict, dataclass, field from logging.handlers import TimedRotatingFileHandler from pathlib import Path -from typing import Any, List, Mapping, MutableMapping, Optional, Sequence +from typing import Any, Iterable, List, Mapping, MutableMapping, Optional, Sequence, Tuple try: # pragma: no-cover - optional dependency during early migration stages. from Modules.crypto import certificates # type: ignore @@ -139,6 +139,7 @@ class EngineSettings: tls_key_path: Optional[str] tls_bundle_path: Optional[str] log_file: str + api_groups: Tuple[str, ...] raw: MutableMapping[str, Any] = field(default_factory=dict) def to_flask_config(self) -> MutableMapping[str, Any]: @@ -158,6 +159,19 @@ class EngineSettings: return data +def _parse_api_groups(raw: Optional[Any]) -> Tuple[str, ...]: + if raw is None: + return tuple() + if isinstance(raw, str): + parts: Iterable[str] = (part.strip() for part in raw.split(",")) + elif isinstance(raw, Sequence): + parts = (str(part).strip() for part in raw) + else: + return tuple() + cleaned = [part.lower() for part in parts if part] + return tuple(dict.fromkeys(cleaned)) + + def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> EngineSettings: """Resolve Engine configuration values. @@ -205,6 +219,12 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine log_file = str(runtime_config.get("LOG_FILE") or LOG_FILE_PATH) _ensure_parent(Path(log_file)) + api_groups = _parse_api_groups( + runtime_config.get("API_GROUPS") or os.environ.get("BOREALIS_API_GROUPS") + ) + if not api_groups: + api_groups = ("tokens", "enrollment") + settings = EngineSettings( database_path=database_path, static_folder=static_folder, @@ -217,6 +237,7 @@ def load_runtime_config(overrides: Optional[Mapping[str, Any]] = None) -> Engine tls_key_path=tls_key_path if tls_key_path else None, tls_bundle_path=tls_bundle_path if tls_bundle_path else None, log_file=str(log_file), + api_groups=api_groups, raw=runtime_config, ) return settings diff --git a/Data/Engine/server.py b/Data/Engine/server.py index 244577d..0499b53 100644 --- a/Data/Engine/server.py +++ b/Data/Engine/server.py @@ -14,7 +14,7 @@ import ssl import sys from dataclasses import dataclass from pathlib import Path -from typing import Any, Mapping, Optional, Tuple +from typing import Any, Mapping, Optional, Sequence, Tuple import eventlet from flask import Flask @@ -103,6 +103,7 @@ class EngineContext: tls_key_path: Optional[str] tls_bundle_path: Optional[str] config: Mapping[str, Any] + api_groups: Sequence[str] __all__ = ["EngineContext", "create_app"] @@ -154,6 +155,7 @@ def create_app(config: Optional[Mapping[str, Any]] = None) -> Tuple[Flask, Socke tls_key_path=tls_key_path, tls_bundle_path=tls_bundle_path, config=settings.as_dict(), + api_groups=settings.api_groups, ) from .services import API, WebSocket, WebUI # Local import to avoid circular deps during bootstrap diff --git a/Data/Engine/services/API/__init__.py b/Data/Engine/services/API/__init__.py index c60504c..3fee35e 100644 --- a/Data/Engine/services/API/__init__.py +++ b/Data/Engine/services/API/__init__.py @@ -1,21 +1,208 @@ -"""API service stubs for the Borealis Engine runtime. +"""API service adapters for the Borealis Engine runtime. -Stage 1 only establishes the package layout. Future stages will populate this -module with blueprint factories that wrap the legacy API helpers. +Stage 3 of the migration introduces blueprint registration that mirrors the +behaviour of :mod:`Data.Server.server` by delegating to the existing domain +modules under ``Data/Server/Modules``. Each adapter wires the Engine context +into the legacy registration helpers so routes continue to function while +configuration toggles control which API groups are exposed. """ from __future__ import annotations +import datetime as _dt +import logging +import re +import sqlite3 +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable, Iterable, Mapping, Optional, Sequence + from flask import Flask +from Modules.auth import jwt_service as jwt_service_module +from Modules.auth.dpop import DPoPValidator +from Modules.auth.rate_limit import SlidingWindowRateLimiter +from Modules.crypto import signing +from Modules.enrollment import routes as enrollment_routes +from Modules.enrollment.nonce_store import NonceCache +from Modules.tokens import routes as token_routes + from ...server import EngineContext +DEFAULT_API_GROUPS: Sequence[str] = ("tokens", "enrollment") + +_SERVER_SCOPE_PATTERN = re.compile(r"\b(?:scope|context|agent_context)=([A-Za-z0-9_-]+)", re.IGNORECASE) +_SERVER_AGENT_ID_PATTERN = re.compile(r"\bagent_id=([^\s,]+)", re.IGNORECASE) + + +def _canonical_server_scope(raw: Optional[str]) -> Optional[str]: + if not raw: + return None + value = "".join(ch for ch in str(raw) if ch.isalnum() or ch in ("_", "-")) + if not value: + return None + return value.upper() + + +def _scope_from_agent_id(agent_id: Optional[str]) -> Optional[str]: + candidate = _canonical_server_scope(agent_id) + if not candidate: + return None + if candidate.endswith("_SYSTEM"): + return "SYSTEM" + if candidate.endswith("_CURRENTUSER"): + return "CURRENTUSER" + return candidate + + +def _infer_server_scope(message: str, explicit: Optional[str]) -> Optional[str]: + scope = _canonical_server_scope(explicit) + if scope: + return scope + match = _SERVER_SCOPE_PATTERN.search(message or "") + if match: + scope = _canonical_server_scope(match.group(1)) + if scope: + return scope + agent_match = _SERVER_AGENT_ID_PATTERN.search(message or "") + if agent_match: + scope = _scope_from_agent_id(agent_match.group(1)) + if scope: + return scope + return None + + +def _rotate_daily(path: Path) -> None: + try: + if not path.is_file(): + return + stat = path.stat() + modified = _dt.datetime.fromtimestamp(stat.st_mtime) + if modified.date() == _dt.datetime.now().date(): + return + suffix = modified.strftime("%Y-%m-%d") + rotated = path.with_name(f"{path.name}.{suffix}") + if rotated.exists(): + return + path.rename(rotated) + except Exception: + pass + + +def _make_service_logger(base: Path, logger: logging.Logger) -> Callable[[str, str, Optional[str]], None]: + def _log(service: str, msg: str, scope: Optional[str] = None, *, level: str = "INFO") -> None: + level_upper = level.upper() + try: + base.mkdir(parents=True, exist_ok=True) + path = base / f"{service}.log" + _rotate_daily(path) + timestamp = time.strftime("%Y-%m-%d %H:%M:%S") + resolved_scope = _infer_server_scope(msg, scope) + prefix_parts = [f"[{level_upper}]"] + if resolved_scope: + prefix_parts.append(f"[CONTEXT-{resolved_scope}]") + prefix = "".join(prefix_parts) + with path.open("a", encoding="utf-8") as handle: + handle.write(f"[{timestamp}] {prefix} {msg}\n") + except Exception: + logger.debug("Failed to write service log entry", exc_info=True) + + numeric_level = getattr(logging, level_upper, logging.INFO) + logger.log(numeric_level, "[service:%s] %s", service, msg) + + return _log + + +def _make_db_conn_factory(database_path: str) -> Callable[[], sqlite3.Connection]: + def _factory() -> sqlite3.Connection: + conn = sqlite3.connect(database_path, timeout=15) + try: + cur = conn.cursor() + cur.execute("PRAGMA journal_mode=WAL") + cur.execute("PRAGMA busy_timeout=5000") + cur.execute("PRAGMA synchronous=NORMAL") + conn.commit() + except Exception: + pass + return conn + + return _factory + + +@dataclass +class LegacyServiceAdapters: + context: EngineContext + db_conn_factory: Callable[[], sqlite3.Connection] = field(init=False) + jwt_service: Any = field(init=False) + dpop_validator: DPoPValidator = field(init=False) + ip_rate_limiter: SlidingWindowRateLimiter = field(init=False) + fp_rate_limiter: SlidingWindowRateLimiter = field(init=False) + nonce_cache: NonceCache = field(init=False) + script_signer: Any = field(init=False) + service_log: Callable[[str, str, Optional[str]], None] = field(init=False) + + def __post_init__(self) -> None: + self.db_conn_factory = _make_db_conn_factory(self.context.database_path) + self.jwt_service = jwt_service_module.load_service() + self.dpop_validator = DPoPValidator() + self.ip_rate_limiter = SlidingWindowRateLimiter() + self.fp_rate_limiter = SlidingWindowRateLimiter() + self.nonce_cache = NonceCache() + try: + self.script_signer = signing.load_signer() + except Exception: + self.script_signer = None + + log_file = str(self.context.config.get("log_file") or self.context.config.get("LOG_FILE") or "") + if log_file: + base = Path(log_file).resolve().parent + else: + base = Path(self.context.database_path).resolve().parent + self.service_log = _make_service_logger(base, self.context.logger) + + +def _register_tokens(app: Flask, adapters: LegacyServiceAdapters) -> None: + token_routes.register( + app, + db_conn_factory=adapters.db_conn_factory, + jwt_service=adapters.jwt_service, + dpop_validator=adapters.dpop_validator, + ) + + +def _register_enrollment(app: Flask, adapters: LegacyServiceAdapters) -> None: + tls_bundle = adapters.context.tls_bundle_path or "" + enrollment_routes.register( + app, + db_conn_factory=adapters.db_conn_factory, + log=adapters.service_log, + jwt_service=adapters.jwt_service, + tls_bundle_path=tls_bundle, + ip_rate_limiter=adapters.ip_rate_limiter, + fp_rate_limiter=adapters.fp_rate_limiter, + nonce_cache=adapters.nonce_cache, + script_signer=adapters.script_signer, + ) + + +_GROUP_REGISTRARS: Mapping[str, Callable[[Flask, LegacyServiceAdapters], None]] = { + "tokens": _register_tokens, + "enrollment": _register_enrollment, +} + def register_api(app: Flask, context: EngineContext) -> None: - """Placeholder hook for API blueprint registration. + """Register Engine API blueprints based on the enabled groups.""" - Later migration stages will import domain-specific blueprint modules and - attach them to ``app`` using the shared :class:`EngineContext`. For now we - simply log the intent so tooling can verify the hook is wired. - """ + enabled_groups: Iterable[str] = context.api_groups or DEFAULT_API_GROUPS + normalized = [group.strip().lower() for group in enabled_groups if group] + adapters = LegacyServiceAdapters(context) + + for group in normalized: + registrar = _GROUP_REGISTRARS.get(group) + if registrar is None: + context.logger.info("Engine API group '%s' is not implemented; skipping.", group) + continue + registrar(app, adapters) + context.logger.info("Engine registered API group '%s'.", group) - context.logger.debug("Engine API services are not yet implemented.")