Assembly Management Rework - Stage 3 Complete

This commit is contained in:
2025-11-02 21:15:56 -07:00
parent 9b5074ed75
commit fdd95bad23
6 changed files with 672 additions and 83 deletions

View File

@@ -77,9 +77,9 @@
- Added Dev Mode toggle/flush endpoints plus official-domain sync, all wired to the cache write queue and importer; verified via operator testing of the API list/update/clone paths. - Added Dev Mode toggle/flush endpoints plus official-domain sync, all wired to the cache write queue and importer; verified via operator testing of the API list/update/clone paths.
## 3. Implement Dev Mode authorization and UX toggles ## 3. Implement Dev Mode authorization and UX toggles
[ ] Gate privileged writes behind Admin role + Dev Mode toggle. [x] Gate privileged writes behind Admin role + Dev Mode toggle.
[ ] Store Dev Mode state server-side (per-user session or short-lived token) to prevent unauthorized access. [x] Store Dev Mode state server-side (per-user session or short-lived token) to prevent unauthorized access.
[ ] Ensure Dev Mode audit logging for all privileged operations. [x] Ensure Dev Mode audit logging for all privileged operations.
### Details ### Details
``` ```
1. Extend authentication middleware in `Data/Engine/services/auth/` to include role checks and Dev Mode status. 1. Extend authentication middleware in `Data/Engine/services/auth/` to include role checks and Dev Mode status.
@@ -88,6 +88,12 @@
4. Update error responses for insufficient privileges to guide admins to enable Dev Mode. 4. Update error responses for insufficient privileges to guide admins to enable Dev Mode.
``` ```
**Stage Notes**
- Added `Data/Engine/services/auth/` with `DevModeManager` and `RequestAuthContext`, storing Dev Mode grants server-side with configurable TTL enforcement.
- `EngineServiceAdapters` now provisions a shared `DevModeManager`, exposing auth helpers to API groups.
- Updated `Data/Engine/services/API/assemblies/management.py` to enforce admin + Dev Mode checks for privileged mutations, centralise error messaging, and route Dev Mode toggles through the server-side manager.
- Privileged assembly endpoints now emit structured audit entries via the Engine service log, covering success and denial paths for create/update/delete/clone, manual flush, official sync, and Dev Mode state changes.
## 4. Enhance Assembly Editor WebUI ## 4. Enhance Assembly Editor WebUI
[ ] Add “Source” column to AG Grid with domain filter badges. [ ] Add “Source” column to AG Grid with domain filter badges.
[ ] Display yellow “Queued to Write to DB” pill for assemblies whose cache entry is dirty. [ ] Display yellow “Queued to Write to DB” pill for assemblies whose cache entry is dirty.

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import datetime as _dt import datetime as _dt
import logging import logging
import os
import re import re
import sqlite3 import sqlite3
import time import time
@@ -28,6 +29,7 @@ from ...database import initialise_engine_database
from ...security import signing from ...security import signing
from ...enrollment import NonceCache from ...enrollment import NonceCache
from ...integrations import GitHubIntegration from ...integrations import GitHubIntegration
from ..auth import DevModeManager
from .enrollment import routes as enrollment_routes from .enrollment import routes as enrollment_routes
from .tokens import routes as token_routes from .tokens import routes as token_routes
@@ -142,6 +144,7 @@ def _make_db_conn_factory(database_path: str) -> Callable[[], sqlite3.Connection
@dataclass @dataclass
class EngineServiceAdapters: class EngineServiceAdapters:
context: EngineContext context: EngineContext
config: Mapping[str, Any] = field(init=False)
db_conn_factory: Callable[[], sqlite3.Connection] = field(init=False) db_conn_factory: Callable[[], sqlite3.Connection] = field(init=False)
jwt_service: Any = field(init=False) jwt_service: Any = field(init=False)
dpop_validator: DPoPValidator = field(init=False) dpop_validator: DPoPValidator = field(init=False)
@@ -153,10 +156,12 @@ class EngineServiceAdapters:
service_log: Callable[[str, str, Optional[str]], None] = field(init=False) service_log: Callable[[str, str, Optional[str]], None] = field(init=False)
device_auth_manager: DeviceAuthManager = field(init=False) device_auth_manager: DeviceAuthManager = field(init=False)
github_integration: GitHubIntegration = field(init=False) github_integration: GitHubIntegration = field(init=False)
dev_mode_manager: DevModeManager = field(init=False)
def __post_init__(self) -> None: def __post_init__(self) -> None:
self.db_conn_factory = _make_db_conn_factory(self.context.database_path) self.db_conn_factory = _make_db_conn_factory(self.context.database_path)
initialise_engine_database(self.context.database_path, logger=self.context.logger) initialise_engine_database(self.context.database_path, logger=self.context.logger)
self.config = dict(self.context.config or {})
self.jwt_service = jwt_service_module.load_service() self.jwt_service = jwt_service_module.load_service()
self.dpop_validator = DPoPValidator() self.dpop_validator = DPoPValidator()
self.ip_rate_limiter = SlidingWindowRateLimiter() self.ip_rate_limiter = SlidingWindowRateLimiter()
@@ -167,7 +172,7 @@ class EngineServiceAdapters:
except Exception: except Exception:
self.script_signer = None self.script_signer = None
log_file = str(self.context.config.get("log_file") or self.context.config.get("LOG_FILE") or "") log_file = str(self.config.get("log_file") or self.config.get("LOG_FILE") or "")
if log_file: if log_file:
base = Path(log_file).resolve().parent base = Path(log_file).resolve().parent
else: else:
@@ -183,7 +188,7 @@ class EngineServiceAdapters:
rate_limiter=self.device_rate_limiter, rate_limiter=self.device_rate_limiter,
) )
config = self.context.config or {} config = self.config
cache_root_value = config.get("cache_dir") or config.get("CACHE_DIR") cache_root_value = config.get("cache_dir") or config.get("CACHE_DIR")
if cache_root_value: if cache_root_value:
cache_root = Path(str(cache_root_value)) cache_root = Path(str(cache_root_value))
@@ -209,6 +214,25 @@ class EngineServiceAdapters:
default_ttl_seconds=default_ttl_seconds, default_ttl_seconds=default_ttl_seconds,
) )
env_ttl_raw = os.environ.get("BOREALIS_DEV_MODE_TTL_SECONDS")
try:
env_ttl = int(env_ttl_raw) if env_ttl_raw else None
except (TypeError, ValueError):
env_ttl = None
config_ttl_raw = config.get("assemblies_dev_mode_ttl_seconds")
try:
config_ttl = int(config_ttl_raw) if config_ttl_raw is not None else None
except (TypeError, ValueError):
config_ttl = None
default_ttl = config_ttl or env_ttl or 900
if default_ttl < 60:
default_ttl = 60
self.dev_mode_manager = DevModeManager(
logger=self.context.logger,
default_ttl_seconds=default_ttl,
)
def _register_tokens(app: Flask, adapters: EngineServiceAdapters) -> None: def _register_tokens(app: Flask, adapters: EngineServiceAdapters) -> None:
token_routes.register( token_routes.register(

View File

@@ -19,14 +19,13 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple
from flask import Blueprint, jsonify, request, session from flask import Blueprint, jsonify, request
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from ....assembly_management.models import AssemblyDomain from ....assembly_management.models import AssemblyDomain
from ...assemblies.service import AssemblyRuntimeService from ...assemblies.service import AssemblyRuntimeService
from ...auth import RequestAuthContext
if TYPE_CHECKING: # pragma: no cover - typing aide if TYPE_CHECKING: # pragma: no cover - typing aide
from .. import EngineServiceAdapters from .. import EngineServiceAdapters
@@ -43,81 +42,104 @@ class AssemblyAPIService:
if cache is None: if cache is None:
raise RuntimeError("Assembly cache not initialised; ensure Engine bootstrap executed.") raise RuntimeError("Assembly cache not initialised; ensure Engine bootstrap executed.")
self.runtime = AssemblyRuntimeService(cache, logger=self.logger) self.runtime = AssemblyRuntimeService(cache, logger=self.logger)
self.service_log = adapters.service_log
self.auth = RequestAuthContext(
app=app,
dev_mode_manager=adapters.dev_mode_manager,
config=adapters.config,
logger=self.logger,
)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Authentication helpers # Authentication helpers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def require_user(self) -> Tuple[Optional[Dict[str, Any]], Optional[Tuple[Dict[str, Any], int]]]: def require_user(self) -> Tuple[Optional[Dict[str, Any]], Optional[Tuple[Dict[str, Any], int]]]:
user = self._current_user() return self.auth.require_user()
if not user:
return None, ({"error": "unauthorized"}, 401)
return user, None
def require_admin(self, *, dev_mode_required: bool = False) -> Optional[Tuple[Dict[str, Any], int]]: def require_admin(
user = self._current_user() self,
if not user: *,
return {"error": "unauthorized"}, 401 dev_mode_required: bool = False,
if not self._is_admin(user): user: Optional[Dict[str, Any]] = None,
return {"error": "admin required"}, 403 ) -> Tuple[Optional[Dict[str, Any]], Optional[Tuple[Dict[str, Any], int]]]:
if dev_mode_required and not self._dev_mode_enabled(): actor = user
return {"error": "dev mode required"}, 403 if actor is None:
return None actor, error = self.require_user()
if error:
detail = error[0].get("message") or error[0].get("error") or "authentication required"
self._audit(user=None, action="admin_check", status="denied", detail=detail)
return actor, error
def require_mutation_for_domain(self, domain: AssemblyDomain) -> Optional[Tuple[Dict[str, Any], int]]: if not RequestAuthContext.is_admin(actor):
payload = {
"error": "forbidden",
"message": "Administrator permissions are required for this action.",
}
self._audit(user=actor, action="admin_check", status="denied", detail=payload["message"])
return actor, (payload, 403)
if dev_mode_required and not self.auth.dev_mode_enabled(user=actor):
payload = {
"error": "dev_mode_required",
"message": "Enable Dev Mode from the Assemblies admin controls to continue.",
}
self._audit(user=actor, action="dev_mode_check", status="denied", detail=payload["message"])
return actor, (payload, 403)
return actor, None
def require_mutation_for_domain(
self,
domain: AssemblyDomain,
) -> Tuple[Optional[Dict[str, Any]], Optional[Tuple[Dict[str, Any], int]]]:
user, error = self.require_user() user, error = self.require_user()
if error: if error:
return error detail = error[0].get("message") or error[0].get("error") or "authentication required"
self._audit(user=None, action="mutation_check", domain=domain, status="denied", detail=detail)
return user, error
if domain == AssemblyDomain.USER: if domain == AssemblyDomain.USER:
return None return user, None
if not self._is_admin(user): _, admin_error = self.require_admin(dev_mode_required=True, user=user)
return {"error": "admin required for non-user domains"}, 403 if admin_error:
if not self._dev_mode_enabled(): return user, admin_error
return {"error": "dev mode required for privileged domains"}, 403 return user, None
return None
def _token_serializer(self) -> URLSafeTimedSerializer: # ------------------------------------------------------------------
secret = self.app.secret_key or "borealis-dev-secret" # Audit helpers
return URLSafeTimedSerializer(secret, salt="borealis-auth") # ------------------------------------------------------------------
def _audit(
def _current_user(self) -> Optional[Dict[str, Any]]: self,
username = session.get("username") *,
role = session.get("role") or "User" user: Optional[Dict[str, Any]],
if username: action: str,
return {"username": username, "role": role} domain: Optional[AssemblyDomain] = None,
assembly_guid: Optional[str] = None,
token = self._bearer_token() status: str = "success",
if not token: detail: Optional[str] = None,
return None ) -> None:
max_age = int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30)) actor = user or {}
username = (actor.get("username") or "").strip() or "anonymous"
role = (actor.get("role") or "").strip() or "unknown"
domain_value = domain.value if isinstance(domain, AssemblyDomain) else (domain or "n/a")
dev_mode_flag = self.auth.dev_mode_enabled(user=user) if user else False
parts = [
f"user={username}",
f"role={role}",
f"action={action}",
f"domain={domain_value}",
f"status={status}",
f"dev_mode={'true' if dev_mode_flag else 'false'}",
]
if assembly_guid:
parts.append(f"assembly={assembly_guid}")
if detail:
parts.append(f"detail={detail}")
message = " ".join(parts)
self.logger.info("Assemblies audit - %s", message)
try: try:
data = self._token_serializer().loads(token, max_age=max_age) self.service_log("assemblies", message, scope="ADMIN")
username = data.get("u") except Exception: # pragma: no cover - logging safeguard
role = data.get("r") or "User" self.logger.debug("Failed to write assemblies service log entry.", exc_info=True)
if username:
return {"username": username, "role": role}
except (BadSignature, SignatureExpired, Exception):
return None
return None
def _bearer_token(self) -> Optional[str]:
auth_header = request.headers.get("Authorization") or ""
if auth_header.lower().startswith("bearer "):
return auth_header.split(" ", 1)[1].strip()
cookie_token = request.cookies.get("borealis_auth")
if cookie_token:
return cookie_token
return None
@staticmethod
def _is_admin(user: Dict[str, Any]) -> bool:
role = (user.get("role") or "").strip().lower()
return role == "admin"
def _dev_mode_enabled(self) -> bool:
return bool(session.get("assemblies_dev_mode", False))
def set_dev_mode(self, enabled: bool) -> None:
session["assemblies_dev_mode"] = bool(enabled)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Domain helpers # Domain helpers
@@ -177,16 +199,43 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
domain = service.parse_domain(payload.get("domain")) domain = service.parse_domain(payload.get("domain"))
if domain is None: if domain is None:
return jsonify({"error": "invalid domain"}), 400 return jsonify({"error": "invalid domain"}), 400
error = service.require_mutation_for_domain(domain) user, error = service.require_mutation_for_domain(domain)
pending_guid = str(payload.get("assembly_guid") or "").strip() or None
if error: if error:
detail = error[0].get("message") or error[0].get("error") or "permission denied"
service._audit(user=user, action="create", domain=domain, assembly_guid=pending_guid, status="denied", detail=detail)
return jsonify(error[0]), error[1] return jsonify(error[0]), error[1]
try: try:
record = service.runtime.create_assembly(payload) record = service.runtime.create_assembly(payload)
service._audit(
user=user,
action="create",
domain=domain,
assembly_guid=record.get("assembly_guid"),
status="success",
detail="queued",
)
return jsonify(record), 201 return jsonify(record), 201
except ValueError as exc: except ValueError as exc:
service._audit(
user=user,
action="create",
domain=domain,
assembly_guid=pending_guid,
status="failed",
detail=str(exc),
)
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
except Exception: # pragma: no cover - runtime guard except Exception: # pragma: no cover - runtime guard
service.logger.exception("Failed to create assembly.") service.logger.exception("Failed to create assembly.")
service._audit(
user=user,
action="create",
domain=domain,
assembly_guid=pending_guid,
status="error",
detail="internal server error",
)
return jsonify({"error": "internal server error"}), 500 return jsonify({"error": "internal server error"}), 500
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -198,16 +247,49 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
existing = service.runtime.get_cached_entry(assembly_guid) existing = service.runtime.get_cached_entry(assembly_guid)
if not existing: if not existing:
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
error = service.require_mutation_for_domain(existing.domain) user, error = service.require_mutation_for_domain(existing.domain)
if error: if error:
detail = error[0].get("message") or error[0].get("error") or "permission denied"
service._audit(
user=user,
action="update",
domain=existing.domain,
assembly_guid=assembly_guid,
status="denied",
detail=detail,
)
return jsonify(error[0]), error[1] return jsonify(error[0]), error[1]
try: try:
record = service.runtime.update_assembly(assembly_guid, payload) record = service.runtime.update_assembly(assembly_guid, payload)
service._audit(
user=user,
action="update",
domain=existing.domain,
assembly_guid=assembly_guid,
status="success",
detail="queued",
)
return jsonify(record), 200 return jsonify(record), 200
except ValueError as exc: except ValueError as exc:
service._audit(
user=user,
action="update",
domain=existing.domain,
assembly_guid=assembly_guid,
status="failed",
detail=str(exc),
)
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
except Exception: # pragma: no cover - runtime guard except Exception: # pragma: no cover - runtime guard
service.logger.exception("Failed to update assembly %s.", assembly_id) service.logger.exception("Failed to update assembly %s.", assembly_guid)
service._audit(
user=user,
action="update",
domain=existing.domain,
assembly_guid=assembly_guid,
status="error",
detail="internal server error",
)
return jsonify({"error": "internal server error"}), 500 return jsonify({"error": "internal server error"}), 500
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -218,16 +300,49 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
existing = service.runtime.get_cached_entry(assembly_guid) existing = service.runtime.get_cached_entry(assembly_guid)
if not existing: if not existing:
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
error = service.require_mutation_for_domain(existing.domain) user, error = service.require_mutation_for_domain(existing.domain)
if error: if error:
detail = error[0].get("message") or error[0].get("error") or "permission denied"
service._audit(
user=user,
action="delete",
domain=existing.domain,
assembly_guid=assembly_guid,
status="denied",
detail=detail,
)
return jsonify(error[0]), error[1] return jsonify(error[0]), error[1]
try: try:
service.runtime.delete_assembly(assembly_guid) service.runtime.delete_assembly(assembly_guid)
service._audit(
user=user,
action="delete",
domain=existing.domain,
assembly_guid=assembly_guid,
status="success",
detail="queued",
)
return jsonify({"status": "queued"}), 202 return jsonify({"status": "queued"}), 202
except ValueError as exc: except ValueError as exc:
service._audit(
user=user,
action="delete",
domain=existing.domain,
assembly_guid=assembly_guid,
status="failed",
detail=str(exc),
)
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
except Exception: # pragma: no cover except Exception: # pragma: no cover
service.logger.exception("Failed to delete assembly %s.", assembly_id) service.logger.exception("Failed to delete assembly %s.", assembly_guid)
service._audit(
user=user,
action="delete",
domain=existing.domain,
assembly_guid=assembly_guid,
status="error",
detail="internal server error",
)
return jsonify({"error": "internal server error"}), 500 return jsonify({"error": "internal server error"}), 500
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -240,8 +355,18 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
domain = service.parse_domain(target_domain_value) domain = service.parse_domain(target_domain_value)
if domain is None: if domain is None:
return jsonify({"error": "invalid target domain"}), 400 return jsonify({"error": "invalid target domain"}), 400
error = service.require_mutation_for_domain(domain) user, error = service.require_mutation_for_domain(domain)
pending_guid = str(payload.get("new_assembly_guid") or "").strip() or None
if error: if error:
detail = error[0].get("message") or error[0].get("error") or "permission denied"
service._audit(
user=user,
action="clone",
domain=domain,
assembly_guid=pending_guid or assembly_guid,
status="denied",
detail=detail,
)
return jsonify(error[0]), error[1] return jsonify(error[0]), error[1]
new_guid = payload.get("new_assembly_guid") new_guid = payload.get("new_assembly_guid")
try: try:
@@ -250,11 +375,35 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
target_domain=domain.value, target_domain=domain.value,
new_assembly_guid=new_guid, new_assembly_guid=new_guid,
) )
service._audit(
user=user,
action="clone",
domain=domain,
assembly_guid=record.get("assembly_guid"),
status="success",
detail=f"source={assembly_guid}",
)
return jsonify(record), 201 return jsonify(record), 201
except ValueError as exc: except ValueError as exc:
service._audit(
user=user,
action="clone",
domain=domain,
assembly_guid=pending_guid or assembly_guid,
status="failed",
detail=str(exc),
)
return jsonify({"error": str(exc)}), 400 return jsonify({"error": str(exc)}), 400
except Exception: # pragma: no cover except Exception: # pragma: no cover
service.logger.exception("Failed to clone assembly %s.", assembly_id) service.logger.exception("Failed to clone assembly %s.", assembly_guid)
service._audit(
user=user,
action="clone",
domain=domain,
assembly_guid=pending_guid or assembly_guid,
status="error",
detail="internal server error",
)
return jsonify({"error": "internal server error"}), 500 return jsonify({"error": "internal server error"}), 500
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -262,27 +411,35 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@blueprint.route("/dev-mode/switch", methods=["POST"]) @blueprint.route("/dev-mode/switch", methods=["POST"])
def switch_dev_mode(): def switch_dev_mode():
error = service.require_admin() user, error = service.require_admin()
if error: if error:
return jsonify(error[0]), error[1] return jsonify(error[0]), error[1]
payload = request.get_json(silent=True) or {} payload = request.get_json(silent=True) or {}
enabled = bool(payload.get("enabled")) enabled = bool(payload.get("enabled"))
service.set_dev_mode(enabled) try:
return jsonify({"dev_mode": service._dev_mode_enabled()}), 200 final_state = service.auth.set_dev_mode(enabled)
except PermissionError: # pragma: no cover - defensive
service._audit(user=user, action="dev_mode_toggle", status="error", detail="state persistence failure")
return jsonify({"error": "unable to update dev mode state"}), 500
detail = "enabled" if final_state else "disabled"
service._audit(user=user, action="dev_mode_toggle", status="success", detail=detail)
return jsonify({"dev_mode": final_state}), 200
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Immediate flush # Immediate flush
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@blueprint.route("/dev-mode/write", methods=["POST"]) @blueprint.route("/dev-mode/write", methods=["POST"])
def flush_assemblies(): def flush_assemblies():
error = service.require_admin(dev_mode_required=True) user, error = service.require_admin(dev_mode_required=True)
if error: if error:
return jsonify(error[0]), error[1] return jsonify(error[0]), error[1]
try: try:
service.runtime.flush_writes() service.runtime.flush_writes()
service._audit(user=user, action="flush_queue", status="success", detail="manual flush")
return jsonify({"status": "flushed"}), 200 return jsonify({"status": "flushed"}), 200
except Exception: # pragma: no cover except Exception: # pragma: no cover
service.logger.exception("Failed to flush assembly queue.") service.logger.exception("Failed to flush assembly queue.")
service._audit(user=user, action="flush_queue", status="error", detail="internal server error")
return jsonify({"error": "internal server error"}), 500 return jsonify({"error": "internal server error"}), 500
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -290,14 +447,16 @@ def register_assemblies(app, adapters: "EngineServiceAdapters") -> None:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@blueprint.route("/official/sync", methods=["POST"]) @blueprint.route("/official/sync", methods=["POST"])
def sync_official(): def sync_official():
error = service.require_admin(dev_mode_required=True) user, error = service.require_admin(dev_mode_required=True)
if error: if error:
return jsonify(error[0]), error[1] return jsonify(error[0]), error[1]
try: try:
service.runtime.sync_official() service.runtime.sync_official()
service._audit(user=user, action="sync_official", status="success")
return jsonify({"status": "synced"}), 200 return jsonify({"status": "synced"}), 200
except Exception: # pragma: no cover except Exception: # pragma: no cover
service.logger.exception("Official assembly sync failed.") service.logger.exception("Official assembly sync failed.")
service._audit(user=user, action="sync_official", status="error", detail="internal server error")
return jsonify({"error": "internal server error"}), 500 return jsonify({"error": "internal server error"}), 500
app.register_blueprint(blueprint) app.register_blueprint(blueprint)

View File

@@ -0,0 +1,14 @@
# ======================================================
# Data\Engine\services\auth\__init__.py
# Description: Exposes shared authentication helpers for Engine REST services.
#
# API Endpoints (if applicable): None
# ======================================================
"""Authentication utilities for Borealis Engine services."""
from .context import RequestAuthContext, PermissionResult
from .dev_mode import DevModeEntry, DevModeManager
__all__ = ["RequestAuthContext", "PermissionResult", "DevModeEntry", "DevModeManager"]

View File

@@ -0,0 +1,199 @@
# ======================================================
# Data\Engine\services\auth\context.py
# Description: Provides request-scoped authentication helpers with Dev Mode state integration.
#
# API Endpoints (if applicable): None
# ======================================================
"""Shared authentication helpers for Engine REST services."""
from __future__ import annotations
import logging
import os
import uuid
from typing import Any, Dict, Mapping, Optional, Tuple
from flask import Request, request, session
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
from .dev_mode import DevModeManager
PermissionResult = Tuple[Dict[str, Any], int]
def _coerce_positive_int(value: Any, default: int) -> int:
try:
candidate = int(value)
if candidate > 0:
return candidate
except (TypeError, ValueError):
pass
return default
class RequestAuthContext:
"""Resolves the current operator and Dev Mode state for the active request."""
def __init__(
self,
*,
app,
dev_mode_manager: DevModeManager,
config: Optional[Mapping[str, Any]] = None,
logger: Optional[logging.Logger] = None,
) -> None:
self._app = app
self._dev_mode_manager = dev_mode_manager
self._config = config or {}
self._logger = logger or logging.getLogger(__name__)
secret = app.secret_key or "borealis-dev-secret"
self._serializer = URLSafeTimedSerializer(secret, salt="borealis-auth")
default_token_ttl = int(os.environ.get("BOREALIS_TOKEN_TTL_SECONDS", 60 * 60 * 24 * 30))
self._token_ttl = _coerce_positive_int(
self._config.get("auth_token_ttl_seconds"), default_token_ttl
)
default_dev_mode_ttl = int(os.environ.get("BOREALIS_DEV_MODE_TTL_SECONDS", 900))
self._dev_mode_ttl = _coerce_positive_int(
self._config.get("assemblies_dev_mode_ttl_seconds"), default_dev_mode_ttl
)
# ------------------------------------------------------------------
# Session helpers
# ------------------------------------------------------------------
def ensure_session_token(self) -> str:
"""Ensure an opaque session token exists for Dev Mode tracking."""
token = session.get("dev_mode_session")
if not token:
token = uuid.uuid4().hex
session["dev_mode_session"] = token
session.modified = True
return token
def current_user(self) -> Optional[Dict[str, Any]]:
"""Return the authenticated operator profile, if any."""
username = session.get("username")
role = session.get("role") or "User"
if username:
return {"username": username, "role": role}
token = self._bearer_token(request)
if not token:
return None
try:
data = self._serializer.loads(token, max_age=self._token_ttl)
except (BadSignature, SignatureExpired):
self._logger.debug("Bearer token invalid or expired.")
return None
except Exception: # pragma: no cover - defensive logging
self._logger.debug("Unexpected error validating bearer token.", exc_info=True)
return None
username = (data.get("u") or "").strip()
role = (data.get("r") or "User").strip() or "User"
if not username:
return None
return {"username": username, "role": role}
def dev_mode_enabled(self, *, user: Optional[Dict[str, Any]] = None) -> bool:
"""Return whether Dev Mode is currently active for the operator."""
profile = user or self.current_user()
if not profile:
return False
token = self.ensure_session_token()
return self._dev_mode_manager.is_enabled(username=profile["username"], session_token=token)
def set_dev_mode(self, enabled: bool, *, ttl_seconds: Optional[int] = None) -> bool:
"""Enable or disable Dev Mode for the active operator session."""
profile = self.current_user()
if not profile:
raise PermissionError("Authentication required to modify Dev Mode state.") # type: ignore[return-value]
token = self.ensure_session_token()
if enabled:
ttl = ttl_seconds if ttl_seconds and ttl_seconds > 0 else self._dev_mode_ttl
self._dev_mode_manager.enable(
username=profile["username"],
role=profile.get("role") or "User",
session_token=token,
ttl_seconds=ttl,
)
else:
self._dev_mode_manager.disable(username=profile["username"], session_token=token)
return self.dev_mode_enabled(user=profile)
def clear_dev_mode(self) -> None:
"""Explicitly disable Dev Mode for the active operator."""
profile = self.current_user()
if not profile:
return
token = self.ensure_session_token()
self._dev_mode_manager.disable(username=profile["username"], session_token=token)
# ------------------------------------------------------------------
# Permission helpers
# ------------------------------------------------------------------
def require_user(self) -> Tuple[Optional[Dict[str, Any]], Optional[PermissionResult]]:
user = self.current_user()
if not user:
return None, (
{
"error": "unauthorized",
"message": "Authentication required. Please sign in and retry.",
},
401,
)
return user, None
def require_admin(self, *, dev_mode_required: bool = False) -> Optional[PermissionResult]:
user = self.current_user()
if not user:
return (
{
"error": "unauthorized",
"message": "Authentication required. Please sign in and retry.",
},
401,
)
if not self.is_admin(user):
return (
{
"error": "forbidden",
"message": "Administrator permissions are required for this action.",
},
403,
)
if dev_mode_required and not self.dev_mode_enabled(user=user):
return (
{
"error": "dev_mode_required",
"message": "Enable Dev Mode from the Assemblies admin controls to continue.",
},
403,
)
return None
# ------------------------------------------------------------------
# Static helpers
# ------------------------------------------------------------------
@staticmethod
def is_admin(user: Dict[str, Any]) -> bool:
role = (user.get("role") or "").strip().lower()
return role == "admin"
@staticmethod
def _bearer_token(req: Request) -> Optional[str]:
header = (req.headers.get("Authorization") or "").strip()
if header.lower().startswith("bearer "):
return header.split(" ", 1)[1].strip()
cookie_token = req.cookies.get("borealis_auth")
if cookie_token:
return cookie_token
return None
__all__ = ["RequestAuthContext", "PermissionResult"]

View File

@@ -0,0 +1,187 @@
# ======================================================
# Data\Engine\services\auth\dev_mode.py
# Description: Manages in-memory Dev Mode state for operator sessions with TTL enforcement.
#
# API Endpoints (if applicable): None
# ======================================================
"""Server-side Dev Mode state tracking for operator sessions."""
from __future__ import annotations
import datetime as _dt
import logging
import threading
from dataclasses import dataclass
from typing import Dict, Optional, Tuple
def _utcnow() -> _dt.datetime:
return _dt.datetime.utcnow().replace(microsecond=0)
def _normalize_username(value: str) -> str:
return (value or "").strip().lower()
@dataclass(frozen=True)
class DevModeEntry:
"""Represents a Dev Mode grant for an operator."""
username: str
role: str
session_token: str
enabled_at: _dt.datetime
expires_at: _dt.datetime
def is_expired(self, *, now: Optional[_dt.datetime] = None) -> bool:
reference = now or _utcnow()
return reference >= self.expires_at
class DevModeManager:
"""Tracks Dev Mode enablement for operator sessions."""
def __init__(self, *, logger: Optional[logging.Logger] = None, default_ttl_seconds: int = 900) -> None:
ttl = int(default_ttl_seconds or 0)
if ttl < 60:
ttl = 60
self._default_ttl = ttl
self._logger = logger or logging.getLogger(__name__)
self._entries: Dict[Tuple[str, str], DevModeEntry] = {}
self._lock = threading.RLock()
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def enable(
self,
*,
username: str,
role: str,
session_token: str,
ttl_seconds: Optional[int] = None,
) -> DevModeEntry:
"""Enable Dev Mode for the specified operator session."""
normalized = _normalize_username(username)
if not normalized:
raise ValueError("username required for Dev Mode enablement")
token = (session_token or "").strip()
if not token:
raise ValueError("session token required for Dev Mode enablement")
ttl = ttl_seconds if ttl_seconds and ttl_seconds > 0 else self._default_ttl
if ttl < 60:
ttl = 60
now = _utcnow()
entry = DevModeEntry(
username=normalized,
role=(role or "User").strip() or "User",
session_token=token,
enabled_at=now,
expires_at=now + _dt.timedelta(seconds=ttl),
)
with self._lock:
self._prune_locked(now=now)
self._entries[(normalized, token)] = entry
self._logger.info(
"Dev Mode enabled for user='%s' role='%s' session_suffix='%s' ttl_seconds=%s",
normalized,
entry.role,
token[-6:],
ttl,
)
return entry
def disable(self, *, username: str, session_token: str) -> bool:
"""Disable Dev Mode for the specified operator session."""
normalized = _normalize_username(username)
token = (session_token or "").strip()
if not normalized or not token:
return False
removed = False
with self._lock:
removed = self._entries.pop((normalized, token), None) is not None
if removed:
self._logger.info(
"Dev Mode disabled for user='%s' session_suffix='%s'",
normalized,
token[-6:],
)
return removed
def is_enabled(self, *, username: str, session_token: str) -> bool:
"""Return whether Dev Mode is currently enabled for the operator session."""
normalized = _normalize_username(username)
token = (session_token or "").strip()
if not normalized or not token:
return False
now = _utcnow()
with self._lock:
entry = self._entries.get((normalized, token))
if not entry:
self._prune_locked(now=now)
return False
if entry.is_expired(now=now):
self._entries.pop((normalized, token), None)
self._logger.info(
"Dev Mode expired for user='%s' session_suffix='%s'",
normalized,
token[-6:],
)
return False
return True
def describe(self) -> Dict[str, Dict[str, str]]:
"""Return a serialisable snapshot of active Dev Mode entries."""
snapshot: Dict[str, Dict[str, str]] = {}
now = _utcnow()
with self._lock:
self._prune_locked(now=now)
for entry in self._entries.values():
key = f"{entry.username}:{entry.session_token[-6:]}"
snapshot[key] = {
"username": entry.username,
"role": entry.role,
"enabled_at": entry.enabled_at.isoformat(),
"expires_at": entry.expires_at.isoformat(),
}
return snapshot
def clean_expired(self) -> None:
"""Remove expired Dev Mode entries."""
with self._lock:
self._prune_locked(now=_utcnow())
# ------------------------------------------------------------------
# Internal helpers
# ------------------------------------------------------------------
def _prune_locked(self, *, now: Optional[_dt.datetime] = None) -> None:
reference = now or _utcnow()
expired_keys = [
key for key, entry in self._entries.items() if entry.is_expired(now=reference)
]
for key in expired_keys:
entry = self._entries.pop(key, None)
if entry:
self._logger.info(
"Dev Mode expired for user='%s' session_suffix='%s' (prune)",
entry.username,
entry.session_token[-6:],
)
__all__ = ["DevModeManager", "DevModeEntry"]