mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 20:01:57 -06:00
Add operator account management API
This commit is contained in:
@@ -11,6 +11,18 @@ from .token_service import (
|
||||
TokenRefreshErrorCode,
|
||||
TokenService,
|
||||
)
|
||||
from .operator_account_service import (
|
||||
AccountNotFoundError,
|
||||
CannotModifySelfError,
|
||||
InvalidPasswordHashError,
|
||||
InvalidRoleError,
|
||||
LastAdminError,
|
||||
LastUserError,
|
||||
OperatorAccountError,
|
||||
OperatorAccountRecord,
|
||||
OperatorAccountService,
|
||||
UsernameAlreadyExistsError,
|
||||
)
|
||||
from .operator_auth_service import (
|
||||
InvalidCredentialsError,
|
||||
InvalidMFACodeError,
|
||||
@@ -32,6 +44,16 @@ __all__ = [
|
||||
"TokenRefreshError",
|
||||
"TokenRefreshErrorCode",
|
||||
"TokenService",
|
||||
"OperatorAccountService",
|
||||
"OperatorAccountError",
|
||||
"OperatorAccountRecord",
|
||||
"UsernameAlreadyExistsError",
|
||||
"AccountNotFoundError",
|
||||
"LastAdminError",
|
||||
"LastUserError",
|
||||
"CannotModifySelfError",
|
||||
"InvalidRoleError",
|
||||
"InvalidPasswordHashError",
|
||||
"OperatorAuthService",
|
||||
"OperatorAuthError",
|
||||
"InvalidCredentialsError",
|
||||
|
||||
211
Data/Engine/services/auth/operator_account_service.py
Normal file
211
Data/Engine/services/auth/operator_account_service.py
Normal file
@@ -0,0 +1,211 @@
|
||||
"""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",
|
||||
]
|
||||
@@ -22,6 +22,7 @@ from Data.Engine.repositories.sqlite import (
|
||||
from Data.Engine.services.auth import (
|
||||
DeviceAuthService,
|
||||
DPoPValidator,
|
||||
OperatorAccountService,
|
||||
OperatorAuthService,
|
||||
JWTService,
|
||||
TokenService,
|
||||
@@ -49,6 +50,7 @@ class EngineServiceContainer:
|
||||
scheduler_service: SchedulerService
|
||||
github_service: GitHubService
|
||||
operator_auth_service: OperatorAuthService
|
||||
operator_account_service: OperatorAccountService
|
||||
|
||||
|
||||
def build_service_container(
|
||||
@@ -114,6 +116,10 @@ def build_service_container(
|
||||
repository=user_repo,
|
||||
logger=log.getChild("operator_auth"),
|
||||
)
|
||||
operator_account_service = OperatorAccountService(
|
||||
repository=user_repo,
|
||||
logger=log.getChild("operator_accounts"),
|
||||
)
|
||||
|
||||
github_provider = GitHubArtifactProvider(
|
||||
cache_file=settings.github.cache_file,
|
||||
@@ -139,6 +145,7 @@ def build_service_container(
|
||||
scheduler_service=scheduler_service,
|
||||
github_service=github_service,
|
||||
operator_auth_service=operator_auth_service,
|
||||
operator_account_service=operator_account_service,
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user