Files
Borealis-Github-Replica/Data/Engine/services/auth/operator_account_service.py

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",
]