mirror of
https://github.com/bunny-lab-io/Borealis.git
synced 2025-10-26 15:41:58 -06:00
Add operator account management API
This commit is contained in:
@@ -6,7 +6,7 @@ from flask import Flask
|
|||||||
|
|
||||||
from Data.Engine.services.container import EngineServiceContainer
|
from Data.Engine.services.container import EngineServiceContainer
|
||||||
|
|
||||||
from . import admin, agents, auth, enrollment, github, health, job_management, tokens
|
from . import admin, agents, auth, enrollment, github, health, job_management, tokens, users
|
||||||
|
|
||||||
_REGISTRARS = (
|
_REGISTRARS = (
|
||||||
health.register,
|
health.register,
|
||||||
@@ -17,6 +17,7 @@ _REGISTRARS = (
|
|||||||
github.register,
|
github.register,
|
||||||
auth.register,
|
auth.register,
|
||||||
admin.register,
|
admin.register,
|
||||||
|
users.register,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
185
Data/Engine/interfaces/http/users.py
Normal file
185
Data/Engine/interfaces/http/users.py
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
"""HTTP endpoints for operator account management."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from flask import Blueprint, Flask, jsonify, request, session
|
||||||
|
|
||||||
|
from Data.Engine.services.auth import (
|
||||||
|
AccountNotFoundError,
|
||||||
|
CannotModifySelfError,
|
||||||
|
InvalidPasswordHashError,
|
||||||
|
InvalidRoleError,
|
||||||
|
LastAdminError,
|
||||||
|
LastUserError,
|
||||||
|
OperatorAccountService,
|
||||||
|
UsernameAlreadyExistsError,
|
||||||
|
)
|
||||||
|
from Data.Engine.services.container import EngineServiceContainer
|
||||||
|
|
||||||
|
blueprint = Blueprint("engine_users", __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def register(app: Flask, services: EngineServiceContainer) -> None:
|
||||||
|
blueprint.services = services # type: ignore[attr-defined]
|
||||||
|
app.register_blueprint(blueprint)
|
||||||
|
|
||||||
|
|
||||||
|
def _services() -> EngineServiceContainer:
|
||||||
|
svc = getattr(blueprint, "services", None)
|
||||||
|
if svc is None: # pragma: no cover - defensive
|
||||||
|
raise RuntimeError("user blueprint not initialized")
|
||||||
|
return svc
|
||||||
|
|
||||||
|
|
||||||
|
def _accounts() -> OperatorAccountService:
|
||||||
|
return _services().operator_account_service
|
||||||
|
|
||||||
|
|
||||||
|
def _require_admin():
|
||||||
|
username = session.get("username")
|
||||||
|
role = (session.get("role") or "").strip().lower()
|
||||||
|
if not isinstance(username, str) or not username:
|
||||||
|
return jsonify({"error": "not_authenticated"}), 401
|
||||||
|
if role != "admin":
|
||||||
|
return jsonify({"error": "forbidden"}), 403
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _format_user(record) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"username": record.username,
|
||||||
|
"display_name": record.display_name,
|
||||||
|
"role": record.role,
|
||||||
|
"last_login": record.last_login,
|
||||||
|
"created_at": record.created_at,
|
||||||
|
"updated_at": record.updated_at,
|
||||||
|
"mfa_enabled": 1 if record.mfa_enabled else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/api/users", methods=["GET"])
|
||||||
|
def list_users() -> object:
|
||||||
|
guard = _require_admin()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
records = _accounts().list_accounts()
|
||||||
|
return jsonify({"users": [_format_user(record) for record in records]})
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/api/users", methods=["POST"])
|
||||||
|
def create_user() -> object:
|
||||||
|
guard = _require_admin()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
username = str(payload.get("username") or "").strip()
|
||||||
|
password_sha512 = str(payload.get("password_sha512") or "").strip()
|
||||||
|
role = str(payload.get("role") or "User")
|
||||||
|
display_name = str(payload.get("display_name") or username)
|
||||||
|
|
||||||
|
try:
|
||||||
|
_accounts().create_account(
|
||||||
|
username=username,
|
||||||
|
password_sha512=password_sha512,
|
||||||
|
role=role,
|
||||||
|
display_name=display_name,
|
||||||
|
)
|
||||||
|
except UsernameAlreadyExistsError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 409
|
||||||
|
except (InvalidPasswordHashError, InvalidRoleError) as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/api/users/<username>", methods=["DELETE"])
|
||||||
|
def delete_user(username: str) -> object:
|
||||||
|
guard = _require_admin()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
actor = session.get("username") if isinstance(session.get("username"), str) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
_accounts().delete_account(username, actor=actor)
|
||||||
|
except CannotModifySelfError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except LastUserError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except LastAdminError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except AccountNotFoundError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/api/users/<username>/reset_password", methods=["POST"])
|
||||||
|
def reset_password(username: str) -> object:
|
||||||
|
guard = _require_admin()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
password_sha512 = str(payload.get("password_sha512") or "").strip()
|
||||||
|
|
||||||
|
try:
|
||||||
|
_accounts().reset_password(username, password_sha512)
|
||||||
|
except InvalidPasswordHashError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except AccountNotFoundError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/api/users/<username>/role", methods=["POST"])
|
||||||
|
def change_role(username: str) -> object:
|
||||||
|
guard = _require_admin()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
role = str(payload.get("role") or "").strip()
|
||||||
|
actor = session.get("username") if isinstance(session.get("username"), str) else None
|
||||||
|
|
||||||
|
try:
|
||||||
|
record = _accounts().change_role(username, role, actor=actor)
|
||||||
|
except InvalidRoleError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except LastAdminError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except AccountNotFoundError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
|
||||||
|
if actor and actor.strip().lower() == username.strip().lower():
|
||||||
|
session["role"] = record.role
|
||||||
|
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
@blueprint.route("/api/users/<username>/mfa", methods=["POST"])
|
||||||
|
def update_mfa(username: str) -> object:
|
||||||
|
guard = _require_admin()
|
||||||
|
if guard:
|
||||||
|
return guard
|
||||||
|
|
||||||
|
payload = request.get_json(silent=True) or {}
|
||||||
|
enabled = bool(payload.get("enabled", False))
|
||||||
|
reset_secret = bool(payload.get("reset_secret", False))
|
||||||
|
|
||||||
|
try:
|
||||||
|
_accounts().update_mfa(username, enabled=enabled, reset_secret=reset_secret)
|
||||||
|
except AccountNotFoundError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
|
||||||
|
actor = session.get("username") if isinstance(session.get("username"), str) else None
|
||||||
|
if actor and actor.strip().lower() == username.strip().lower() and not enabled:
|
||||||
|
session.pop("mfa_pending", None)
|
||||||
|
|
||||||
|
return jsonify({"status": "ok"})
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ["register", "blueprint"]
|
||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import logging
|
import logging
|
||||||
import sqlite3
|
import sqlite3
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
from Data.Engine.domain import OperatorAccount
|
from Data.Engine.domain import OperatorAccount
|
||||||
|
|
||||||
@@ -64,23 +64,175 @@ class SQLiteUserRepository:
|
|||||||
if not row:
|
if not row:
|
||||||
return None
|
return None
|
||||||
record = _UserRow(*row)
|
record = _UserRow(*row)
|
||||||
return OperatorAccount(
|
return _row_to_account(record)
|
||||||
username=record.username,
|
|
||||||
display_name=record.display_name or record.username,
|
|
||||||
password_sha512=(record.password_sha512 or "").lower(),
|
|
||||||
role=record.role or "User",
|
|
||||||
last_login=int(record.last_login or 0),
|
|
||||||
created_at=int(record.created_at or 0),
|
|
||||||
updated_at=int(record.updated_at or 0),
|
|
||||||
mfa_enabled=bool(record.mfa_enabled),
|
|
||||||
mfa_secret=(record.mfa_secret or "") or None,
|
|
||||||
)
|
|
||||||
except sqlite3.Error as exc: # pragma: no cover - defensive
|
except sqlite3.Error as exc: # pragma: no cover - defensive
|
||||||
self._log.error("failed to load user %s: %s", username, exc)
|
self._log.error("failed to load user %s: %s", username, exc)
|
||||||
return None
|
return None
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def list_accounts(self) -> list[OperatorAccount]:
|
||||||
|
conn = self._connection_factory()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
username,
|
||||||
|
display_name,
|
||||||
|
COALESCE(password_sha512, '') as password_sha512,
|
||||||
|
COALESCE(role, 'User') as role,
|
||||||
|
COALESCE(last_login, 0) as last_login,
|
||||||
|
COALESCE(created_at, 0) as created_at,
|
||||||
|
COALESCE(updated_at, 0) as updated_at,
|
||||||
|
COALESCE(mfa_enabled, 0) as mfa_enabled,
|
||||||
|
COALESCE(mfa_secret, '') as mfa_secret
|
||||||
|
FROM users
|
||||||
|
ORDER BY LOWER(username) ASC
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
rows = [_UserRow(*row) for row in cur.fetchall()]
|
||||||
|
return [_row_to_account(row) for row in rows]
|
||||||
|
except sqlite3.Error as exc: # pragma: no cover - defensive
|
||||||
|
self._log.error("failed to enumerate users: %s", exc)
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def create_account(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
username: str,
|
||||||
|
display_name: str,
|
||||||
|
password_sha512: str,
|
||||||
|
role: str,
|
||||||
|
timestamp: int,
|
||||||
|
) -> None:
|
||||||
|
conn = self._connection_factory()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
username,
|
||||||
|
display_name,
|
||||||
|
password_sha512,
|
||||||
|
role,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(username, display_name, password_sha512, role, timestamp, timestamp),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def delete_account(self, username: str) -> bool:
|
||||||
|
conn = self._connection_factory()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute("DELETE FROM users WHERE LOWER(username) = LOWER(?)", (username,))
|
||||||
|
deleted = cur.rowcount > 0
|
||||||
|
conn.commit()
|
||||||
|
return deleted
|
||||||
|
except sqlite3.Error as exc: # pragma: no cover - defensive
|
||||||
|
self._log.error("failed to delete user %s: %s", username, exc)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_password(self, username: str, password_sha512: str, *, timestamp: int) -> bool:
|
||||||
|
conn = self._connection_factory()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET password_sha512 = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE LOWER(username) = LOWER(?)
|
||||||
|
""",
|
||||||
|
(password_sha512, timestamp, username),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
except sqlite3.Error as exc: # pragma: no cover - defensive
|
||||||
|
self._log.error("failed to update password for %s: %s", username, exc)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_role(self, username: str, role: str, *, timestamp: int) -> bool:
|
||||||
|
conn = self._connection_factory()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET role = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE LOWER(username) = LOWER(?)
|
||||||
|
""",
|
||||||
|
(role, timestamp, username),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
except sqlite3.Error as exc: # pragma: no cover - defensive
|
||||||
|
self._log.error("failed to update role for %s: %s", username, exc)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def update_mfa(
|
||||||
|
self,
|
||||||
|
username: str,
|
||||||
|
*,
|
||||||
|
enabled: bool,
|
||||||
|
reset_secret: bool,
|
||||||
|
timestamp: int,
|
||||||
|
) -> bool:
|
||||||
|
conn = self._connection_factory()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
secret_clause = "mfa_secret = NULL" if reset_secret else None
|
||||||
|
assignments: list[str] = ["mfa_enabled = ?", "updated_at = ?"]
|
||||||
|
params: list[object] = [1 if enabled else 0, timestamp]
|
||||||
|
if secret_clause is not None:
|
||||||
|
assignments.append(secret_clause)
|
||||||
|
query = "UPDATE users SET " + ", ".join(assignments) + " WHERE LOWER(username) = LOWER(?)"
|
||||||
|
params.append(username)
|
||||||
|
cur.execute(query, tuple(params))
|
||||||
|
conn.commit()
|
||||||
|
return cur.rowcount > 0
|
||||||
|
except sqlite3.Error as exc: # pragma: no cover - defensive
|
||||||
|
self._log.error("failed to update MFA for %s: %s", username, exc)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def count_accounts(self) -> int:
|
||||||
|
return self._scalar("SELECT COUNT(*) FROM users", ())
|
||||||
|
|
||||||
|
def count_admins(self) -> int:
|
||||||
|
return self._scalar("SELECT COUNT(*) FROM users WHERE LOWER(role) = 'admin'", ())
|
||||||
|
|
||||||
|
def _scalar(self, query: str, params: Iterable[object]) -> int:
|
||||||
|
conn = self._connection_factory()
|
||||||
|
try:
|
||||||
|
cur = conn.cursor()
|
||||||
|
cur.execute(query, tuple(params))
|
||||||
|
row = cur.fetchone()
|
||||||
|
if not row:
|
||||||
|
return 0
|
||||||
|
return int(row[0] or 0)
|
||||||
|
except sqlite3.Error as exc: # pragma: no cover - defensive
|
||||||
|
self._log.error("scalar query failed: %s", exc)
|
||||||
|
return 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def update_last_login(self, username: str, timestamp: int) -> None:
|
def update_last_login(self, username: str, timestamp: int) -> None:
|
||||||
conn = self._connection_factory()
|
conn = self._connection_factory()
|
||||||
try:
|
try:
|
||||||
@@ -121,3 +273,17 @@ class SQLiteUserRepository:
|
|||||||
|
|
||||||
|
|
||||||
__all__ = ["SQLiteUserRepository"]
|
__all__ = ["SQLiteUserRepository"]
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_account(record: _UserRow) -> OperatorAccount:
|
||||||
|
return OperatorAccount(
|
||||||
|
username=record.username,
|
||||||
|
display_name=record.display_name or record.username,
|
||||||
|
password_sha512=(record.password_sha512 or "").lower(),
|
||||||
|
role=record.role or "User",
|
||||||
|
last_login=int(record.last_login or 0),
|
||||||
|
created_at=int(record.created_at or 0),
|
||||||
|
updated_at=int(record.updated_at or 0),
|
||||||
|
mfa_enabled=bool(record.mfa_enabled),
|
||||||
|
mfa_secret=(record.mfa_secret or "") or None,
|
||||||
|
)
|
||||||
|
|||||||
@@ -11,6 +11,18 @@ from .token_service import (
|
|||||||
TokenRefreshErrorCode,
|
TokenRefreshErrorCode,
|
||||||
TokenService,
|
TokenService,
|
||||||
)
|
)
|
||||||
|
from .operator_account_service import (
|
||||||
|
AccountNotFoundError,
|
||||||
|
CannotModifySelfError,
|
||||||
|
InvalidPasswordHashError,
|
||||||
|
InvalidRoleError,
|
||||||
|
LastAdminError,
|
||||||
|
LastUserError,
|
||||||
|
OperatorAccountError,
|
||||||
|
OperatorAccountRecord,
|
||||||
|
OperatorAccountService,
|
||||||
|
UsernameAlreadyExistsError,
|
||||||
|
)
|
||||||
from .operator_auth_service import (
|
from .operator_auth_service import (
|
||||||
InvalidCredentialsError,
|
InvalidCredentialsError,
|
||||||
InvalidMFACodeError,
|
InvalidMFACodeError,
|
||||||
@@ -32,6 +44,16 @@ __all__ = [
|
|||||||
"TokenRefreshError",
|
"TokenRefreshError",
|
||||||
"TokenRefreshErrorCode",
|
"TokenRefreshErrorCode",
|
||||||
"TokenService",
|
"TokenService",
|
||||||
|
"OperatorAccountService",
|
||||||
|
"OperatorAccountError",
|
||||||
|
"OperatorAccountRecord",
|
||||||
|
"UsernameAlreadyExistsError",
|
||||||
|
"AccountNotFoundError",
|
||||||
|
"LastAdminError",
|
||||||
|
"LastUserError",
|
||||||
|
"CannotModifySelfError",
|
||||||
|
"InvalidRoleError",
|
||||||
|
"InvalidPasswordHashError",
|
||||||
"OperatorAuthService",
|
"OperatorAuthService",
|
||||||
"OperatorAuthError",
|
"OperatorAuthError",
|
||||||
"InvalidCredentialsError",
|
"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 (
|
from Data.Engine.services.auth import (
|
||||||
DeviceAuthService,
|
DeviceAuthService,
|
||||||
DPoPValidator,
|
DPoPValidator,
|
||||||
|
OperatorAccountService,
|
||||||
OperatorAuthService,
|
OperatorAuthService,
|
||||||
JWTService,
|
JWTService,
|
||||||
TokenService,
|
TokenService,
|
||||||
@@ -49,6 +50,7 @@ class EngineServiceContainer:
|
|||||||
scheduler_service: SchedulerService
|
scheduler_service: SchedulerService
|
||||||
github_service: GitHubService
|
github_service: GitHubService
|
||||||
operator_auth_service: OperatorAuthService
|
operator_auth_service: OperatorAuthService
|
||||||
|
operator_account_service: OperatorAccountService
|
||||||
|
|
||||||
|
|
||||||
def build_service_container(
|
def build_service_container(
|
||||||
@@ -114,6 +116,10 @@ def build_service_container(
|
|||||||
repository=user_repo,
|
repository=user_repo,
|
||||||
logger=log.getChild("operator_auth"),
|
logger=log.getChild("operator_auth"),
|
||||||
)
|
)
|
||||||
|
operator_account_service = OperatorAccountService(
|
||||||
|
repository=user_repo,
|
||||||
|
logger=log.getChild("operator_accounts"),
|
||||||
|
)
|
||||||
|
|
||||||
github_provider = GitHubArtifactProvider(
|
github_provider = GitHubArtifactProvider(
|
||||||
cache_file=settings.github.cache_file,
|
cache_file=settings.github.cache_file,
|
||||||
@@ -139,6 +145,7 @@ def build_service_container(
|
|||||||
scheduler_service=scheduler_service,
|
scheduler_service=scheduler_service,
|
||||||
github_service=github_service,
|
github_service=github_service,
|
||||||
operator_auth_service=operator_auth_service,
|
operator_auth_service=operator_auth_service,
|
||||||
|
operator_account_service=operator_account_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from pathlib import Path
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
pytest.importorskip("flask")
|
pytest.importorskip("flask")
|
||||||
|
pytest.importorskip("jwt")
|
||||||
|
|
||||||
from Data.Engine.config.environment import (
|
from Data.Engine.config.environment import (
|
||||||
DatabaseSettings,
|
DatabaseSettings,
|
||||||
|
|||||||
120
Data/Engine/tests/test_http_users.py
Normal file
120
Data/Engine/tests/test_http_users.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""HTTP integration tests for operator account endpoints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from .test_http_auth import _login, prepared_app
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_requires_authentication(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
resp = client.get("/api/users")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_users_returns_accounts(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
resp = client.get("/api/users")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
payload = resp.get_json()
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
assert "users" in payload
|
||||||
|
assert any(user["username"] == "admin" for user in payload["users"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_user_validates_payload(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
resp = client.post("/api/users", json={"username": "bob"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"username": "bob",
|
||||||
|
"password_sha512": hashlib.sha512(b"pw").hexdigest(),
|
||||||
|
"role": "User",
|
||||||
|
}
|
||||||
|
resp = client.post("/api/users", json=payload)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
# Duplicate username should conflict
|
||||||
|
resp = client.post("/api/users", json=payload)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_handles_edge_cases(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
# cannot delete the only user
|
||||||
|
resp = client.delete("/api/users/admin")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
# create another user then delete them successfully
|
||||||
|
payload = {
|
||||||
|
"username": "alice",
|
||||||
|
"password_sha512": hashlib.sha512(b"pw").hexdigest(),
|
||||||
|
"role": "User",
|
||||||
|
}
|
||||||
|
client.post("/api/users", json=payload)
|
||||||
|
|
||||||
|
resp = client.delete("/api/users/alice")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_user_prevents_self_deletion(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"username": "charlie",
|
||||||
|
"password_sha512": hashlib.sha512(b"pw").hexdigest(),
|
||||||
|
"role": "User",
|
||||||
|
}
|
||||||
|
client.post("/api/users", json=payload)
|
||||||
|
|
||||||
|
resp = client.delete("/api/users/admin")
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_role_updates_session(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"username": "backup",
|
||||||
|
"password_sha512": hashlib.sha512(b"pw").hexdigest(),
|
||||||
|
"role": "Admin",
|
||||||
|
}
|
||||||
|
client.post("/api/users", json=payload)
|
||||||
|
|
||||||
|
resp = client.post("/api/users/backup/role", json={"role": "User"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
resp = client.post("/api/users/admin/role", json={"role": "User"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_password_requires_valid_hash(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
resp = client.post("/api/users/admin/reset_password", json={"password_sha512": "abc"})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
resp = client.post(
|
||||||
|
"/api/users/admin/reset_password",
|
||||||
|
json={"password_sha512": hashlib.sha512(b"new").hexdigest()},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_mfa_returns_not_found_for_unknown_user(prepared_app):
|
||||||
|
client = prepared_app.test_client()
|
||||||
|
_login(client)
|
||||||
|
|
||||||
|
resp = client.post("/api/users/missing/mfa", json={"enabled": True})
|
||||||
|
assert resp.status_code == 404
|
||||||
191
Data/Engine/tests/test_operator_account_service.py
Normal file
191
Data/Engine/tests/test_operator_account_service.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
"""Tests for the operator account management service."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytest.importorskip("jwt")
|
||||||
|
|
||||||
|
from Data.Engine.repositories.sqlite.connection import connection_factory
|
||||||
|
from Data.Engine.repositories.sqlite.user_repository import SQLiteUserRepository
|
||||||
|
from Data.Engine.services.auth.operator_account_service import (
|
||||||
|
AccountNotFoundError,
|
||||||
|
CannotModifySelfError,
|
||||||
|
InvalidPasswordHashError,
|
||||||
|
InvalidRoleError,
|
||||||
|
LastAdminError,
|
||||||
|
LastUserError,
|
||||||
|
OperatorAccountService,
|
||||||
|
UsernameAlreadyExistsError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_db(path: Path) -> Callable[[], sqlite3.Connection]:
|
||||||
|
conn = sqlite3.connect(path)
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE users (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE,
|
||||||
|
display_name TEXT,
|
||||||
|
password_sha512 TEXT,
|
||||||
|
role TEXT,
|
||||||
|
last_login INTEGER,
|
||||||
|
created_at INTEGER,
|
||||||
|
updated_at INTEGER,
|
||||||
|
mfa_enabled INTEGER,
|
||||||
|
mfa_secret TEXT
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return connection_factory(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_user(
|
||||||
|
factory: Callable[[], sqlite3.Connection],
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
username: str,
|
||||||
|
password_hash: str,
|
||||||
|
role: str = "Admin",
|
||||||
|
mfa_enabled: int = 0,
|
||||||
|
mfa_secret: str = "",
|
||||||
|
) -> None:
|
||||||
|
conn = factory()
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO users (
|
||||||
|
id, username, display_name, password_sha512, role,
|
||||||
|
last_login, created_at, updated_at, mfa_enabled, mfa_secret
|
||||||
|
) VALUES (?, ?, ?, ?, ?, 0, 0, 0, ?, ?)
|
||||||
|
""",
|
||||||
|
(user_id, username, username, password_hash, role, mfa_enabled, mfa_secret),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _service(factory: Callable[[], sqlite3.Connection]) -> OperatorAccountService:
|
||||||
|
repo = SQLiteUserRepository(factory)
|
||||||
|
return OperatorAccountService(repo)
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_accounts_returns_users(tmp_path):
|
||||||
|
db = tmp_path / "users.db"
|
||||||
|
factory = _prepare_db(db)
|
||||||
|
password_hash = hashlib.sha512(b"password").hexdigest()
|
||||||
|
_insert_user(factory, user_id="1", username="admin", password_hash=password_hash)
|
||||||
|
|
||||||
|
service = _service(factory)
|
||||||
|
records = service.list_accounts()
|
||||||
|
|
||||||
|
assert len(records) == 1
|
||||||
|
assert records[0].username == "admin"
|
||||||
|
assert records[0].role == "Admin"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_account_enforces_uniqueness(tmp_path):
|
||||||
|
db = tmp_path / "users.db"
|
||||||
|
factory = _prepare_db(db)
|
||||||
|
service = _service(factory)
|
||||||
|
password_hash = hashlib.sha512(b"pw").hexdigest()
|
||||||
|
|
||||||
|
service.create_account(username="admin", password_sha512=password_hash, role="Admin")
|
||||||
|
|
||||||
|
with pytest.raises(UsernameAlreadyExistsError):
|
||||||
|
service.create_account(username="admin", password_sha512=password_hash, role="Admin")
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_account_validates_password_hash(tmp_path):
|
||||||
|
db = tmp_path / "users.db"
|
||||||
|
factory = _prepare_db(db)
|
||||||
|
service = _service(factory)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPasswordHashError):
|
||||||
|
service.create_account(username="user", password_sha512="abc", role="User")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_account_protects_last_user(tmp_path):
|
||||||
|
db = tmp_path / "users.db"
|
||||||
|
factory = _prepare_db(db)
|
||||||
|
password_hash = hashlib.sha512(b"pw").hexdigest()
|
||||||
|
_insert_user(factory, user_id="1", username="admin", password_hash=password_hash)
|
||||||
|
|
||||||
|
service = _service(factory)
|
||||||
|
|
||||||
|
with pytest.raises(LastUserError):
|
||||||
|
service.delete_account("admin")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_account_prevents_self_deletion(tmp_path):
|
||||||
|
db = tmp_path / "users.db"
|
||||||
|
factory = _prepare_db(db)
|
||||||
|
password_hash = hashlib.sha512(b"pw").hexdigest()
|
||||||
|
_insert_user(factory, user_id="1", username="admin", password_hash=password_hash)
|
||||||
|
_insert_user(factory, user_id="2", username="user", password_hash=password_hash, role="User")
|
||||||
|
|
||||||
|
service = _service(factory)
|
||||||
|
|
||||||
|
with pytest.raises(CannotModifySelfError):
|
||||||
|
service.delete_account("admin", actor="admin")
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_account_prevents_last_admin_removal(tmp_path):
|
||||||
|
db = tmp_path / "users.db"
|
||||||
|
factory = _prepare_db(db)
|
||||||
|
password_hash = hashlib.sha512(b"pw").hexdigest()
|
||||||
|
_insert_user(factory, user_id="1", username="admin", password_hash=password_hash)
|
||||||
|
_insert_user(factory, user_id="2", username="user", password_hash=password_hash, role="User")
|
||||||
|
|
||||||
|
service = _service(factory)
|
||||||
|
|
||||||
|
with pytest.raises(LastAdminError):
|
||||||
|
service.delete_account("admin")
|
||||||
|
|
||||||
|
|
||||||
|
def test_change_role_demotes_only_when_valid(tmp_path):
|
||||||
|
db = tmp_path / "users.db"
|
||||||
|
factory = _prepare_db(db)
|
||||||
|
password_hash = hashlib.sha512(b"pw").hexdigest()
|
||||||
|
_insert_user(factory, user_id="1", username="admin", password_hash=password_hash)
|
||||||
|
_insert_user(factory, user_id="2", username="backup", password_hash=password_hash)
|
||||||
|
|
||||||
|
service = _service(factory)
|
||||||
|
service.change_role("backup", "User")
|
||||||
|
|
||||||
|
with pytest.raises(LastAdminError):
|
||||||
|
service.change_role("admin", "User")
|
||||||
|
|
||||||
|
with pytest.raises(InvalidRoleError):
|
||||||
|
service.change_role("admin", "invalid")
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_password_validates_hash(tmp_path):
|
||||||
|
db = tmp_path / "users.db"
|
||||||
|
factory = _prepare_db(db)
|
||||||
|
password_hash = hashlib.sha512(b"pw").hexdigest()
|
||||||
|
_insert_user(factory, user_id="1", username="admin", password_hash=password_hash)
|
||||||
|
|
||||||
|
service = _service(factory)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidPasswordHashError):
|
||||||
|
service.reset_password("admin", "abc")
|
||||||
|
|
||||||
|
new_hash = hashlib.sha512(b"new").hexdigest()
|
||||||
|
service.reset_password("admin", new_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_mfa_raises_for_unknown_user(tmp_path):
|
||||||
|
db = tmp_path / "users.db"
|
||||||
|
factory = _prepare_db(db)
|
||||||
|
service = _service(factory)
|
||||||
|
|
||||||
|
with pytest.raises(AccountNotFoundError):
|
||||||
|
service.update_mfa("missing", enabled=True, reset_secret=False)
|
||||||
Reference in New Issue
Block a user