mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 15:41:58 -06:00
212 lines
7.4 KiB
Python
212 lines
7.4 KiB
Python
"""Operator account management service."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
from dataclasses import dataclass
|
|
from typing import Optional
|
|
|
|
from Data.Engine.domain import OperatorAccount
|
|
from Data.Engine.repositories.sqlite.user_repository import SQLiteUserRepository
|
|
|
|
|
|
class OperatorAccountError(Exception):
|
|
"""Base class for operator account management failures."""
|
|
|
|
|
|
class UsernameAlreadyExistsError(OperatorAccountError):
|
|
"""Raised when attempting to create an operator with a duplicate username."""
|
|
|
|
|
|
class AccountNotFoundError(OperatorAccountError):
|
|
"""Raised when the requested operator account cannot be located."""
|
|
|
|
|
|
class LastAdminError(OperatorAccountError):
|
|
"""Raised when attempting to demote or delete the last remaining admin."""
|
|
|
|
|
|
class LastUserError(OperatorAccountError):
|
|
"""Raised when attempting to delete the final operator account."""
|
|
|
|
|
|
class CannotModifySelfError(OperatorAccountError):
|
|
"""Raised when the caller attempts to delete themselves."""
|
|
|
|
|
|
class InvalidRoleError(OperatorAccountError):
|
|
"""Raised when a role value is invalid."""
|
|
|
|
|
|
class InvalidPasswordHashError(OperatorAccountError):
|
|
"""Raised when a password hash is malformed."""
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class OperatorAccountRecord:
|
|
username: str
|
|
display_name: str
|
|
role: str
|
|
last_login: int
|
|
created_at: int
|
|
updated_at: int
|
|
mfa_enabled: bool
|
|
|
|
|
|
class OperatorAccountService:
|
|
"""High-level operations for managing operator accounts."""
|
|
|
|
def __init__(
|
|
self,
|
|
repository: SQLiteUserRepository,
|
|
*,
|
|
logger: Optional[logging.Logger] = None,
|
|
) -> None:
|
|
self._repository = repository
|
|
self._log = logger or logging.getLogger("borealis.engine.services.operator_accounts")
|
|
|
|
def list_accounts(self) -> list[OperatorAccountRecord]:
|
|
return [_to_record(account) for account in self._repository.list_accounts()]
|
|
|
|
def create_account(
|
|
self,
|
|
*,
|
|
username: str,
|
|
password_sha512: str,
|
|
role: str,
|
|
display_name: Optional[str] = None,
|
|
) -> OperatorAccountRecord:
|
|
normalized_role = self._normalize_role(role)
|
|
username = (username or "").strip()
|
|
password_sha512 = (password_sha512 or "").strip().lower()
|
|
display_name = (display_name or username or "").strip()
|
|
|
|
if not username or not password_sha512:
|
|
raise InvalidPasswordHashError("username and password are required")
|
|
if len(password_sha512) != 128:
|
|
raise InvalidPasswordHashError("password hash must be 128 hex characters")
|
|
|
|
now = int(time.time())
|
|
try:
|
|
self._repository.create_account(
|
|
username=username,
|
|
display_name=display_name or username,
|
|
password_sha512=password_sha512,
|
|
role=normalized_role,
|
|
timestamp=now,
|
|
)
|
|
except Exception as exc: # pragma: no cover - sqlite integrity errors are deterministic
|
|
import sqlite3
|
|
|
|
if isinstance(exc, sqlite3.IntegrityError):
|
|
raise UsernameAlreadyExistsError("username already exists") from exc
|
|
raise
|
|
|
|
account = self._repository.fetch_by_username(username)
|
|
if not account: # pragma: no cover - sanity guard
|
|
raise AccountNotFoundError("account creation failed")
|
|
return _to_record(account)
|
|
|
|
def delete_account(self, username: str, *, actor: Optional[str] = None) -> None:
|
|
username = (username or "").strip()
|
|
if not username:
|
|
raise AccountNotFoundError("invalid username")
|
|
|
|
if actor and actor.strip().lower() == username.lower():
|
|
raise CannotModifySelfError("cannot delete yourself")
|
|
|
|
total_accounts = self._repository.count_accounts()
|
|
if total_accounts <= 1:
|
|
raise LastUserError("cannot delete the last user")
|
|
|
|
target = self._repository.fetch_by_username(username)
|
|
if not target:
|
|
raise AccountNotFoundError("user not found")
|
|
|
|
if target.role.lower() == "admin" and self._repository.count_admins() <= 1:
|
|
raise LastAdminError("cannot delete the last admin")
|
|
|
|
if not self._repository.delete_account(username):
|
|
raise AccountNotFoundError("user not found")
|
|
|
|
def reset_password(self, username: str, password_sha512: str) -> None:
|
|
username = (username or "").strip()
|
|
password_sha512 = (password_sha512 or "").strip().lower()
|
|
if len(password_sha512) != 128:
|
|
raise InvalidPasswordHashError("invalid password hash")
|
|
|
|
now = int(time.time())
|
|
if not self._repository.update_password(username, password_sha512, timestamp=now):
|
|
raise AccountNotFoundError("user not found")
|
|
|
|
def change_role(self, username: str, role: str, *, actor: Optional[str] = None) -> OperatorAccountRecord:
|
|
username = (username or "").strip()
|
|
normalized_role = self._normalize_role(role)
|
|
|
|
account = self._repository.fetch_by_username(username)
|
|
if not account:
|
|
raise AccountNotFoundError("user not found")
|
|
|
|
if account.role.lower() == "admin" and normalized_role.lower() != "admin":
|
|
if self._repository.count_admins() <= 1:
|
|
raise LastAdminError("cannot demote the last admin")
|
|
|
|
now = int(time.time())
|
|
if not self._repository.update_role(username, normalized_role, timestamp=now):
|
|
raise AccountNotFoundError("user not found")
|
|
|
|
updated = self._repository.fetch_by_username(username)
|
|
if not updated: # pragma: no cover - guard
|
|
raise AccountNotFoundError("user not found")
|
|
|
|
record = _to_record(updated)
|
|
if actor and actor.strip().lower() == username.lower():
|
|
self._log.info("actor-role-updated", extra={"username": username, "role": record.role})
|
|
return record
|
|
|
|
def update_mfa(self, username: str, *, enabled: bool, reset_secret: bool) -> None:
|
|
username = (username or "").strip()
|
|
if not username:
|
|
raise AccountNotFoundError("invalid username")
|
|
|
|
now = int(time.time())
|
|
if not self._repository.update_mfa(username, enabled=enabled, reset_secret=reset_secret, timestamp=now):
|
|
raise AccountNotFoundError("user not found")
|
|
|
|
def fetch_account(self, username: str) -> Optional[OperatorAccountRecord]:
|
|
account = self._repository.fetch_by_username(username)
|
|
return _to_record(account) if account else None
|
|
|
|
def _normalize_role(self, role: str) -> str:
|
|
normalized = (role or "").strip().title() or "User"
|
|
if normalized not in {"User", "Admin"}:
|
|
raise InvalidRoleError("invalid role")
|
|
return normalized
|
|
|
|
|
|
def _to_record(account: OperatorAccount) -> OperatorAccountRecord:
|
|
return OperatorAccountRecord(
|
|
username=account.username,
|
|
display_name=account.display_name or account.username,
|
|
role=account.role or "User",
|
|
last_login=int(account.last_login or 0),
|
|
created_at=int(account.created_at or 0),
|
|
updated_at=int(account.updated_at or 0),
|
|
mfa_enabled=bool(account.mfa_enabled),
|
|
)
|
|
|
|
|
|
__all__ = [
|
|
"OperatorAccountService",
|
|
"OperatorAccountError",
|
|
"UsernameAlreadyExistsError",
|
|
"AccountNotFoundError",
|
|
"LastAdminError",
|
|
"LastUserError",
|
|
"CannotModifySelfError",
|
|
"InvalidRoleError",
|
|
"InvalidPasswordHashError",
|
|
"OperatorAccountRecord",
|
|
]
|