mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-16 09:25:48 -07:00
Assembly Management Rework - Stage 3 Complete
This commit is contained in:
14
Data/Engine/services/auth/__init__.py
Normal file
14
Data/Engine/services/auth/__init__.py
Normal 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"]
|
||||
|
||||
199
Data/Engine/services/auth/context.py
Normal file
199
Data/Engine/services/auth/context.py
Normal 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"]
|
||||
187
Data/Engine/services/auth/dev_mode.py
Normal file
187
Data/Engine/services/auth/dev_mode.py
Normal 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"]
|
||||
|
||||
Reference in New Issue
Block a user