mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-12-15 01:55:48 -07:00
Assembly Management Rework - Stage 3 Complete
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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