357 lines
14 KiB
Python
357 lines
14 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Callable
|
|
|
|
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from .auth.keycloak import AuthContext
|
|
from .db.storage import TaskRunRecord
|
|
from .utils.errors import safe_error_detail
|
|
from .utils.logging import task_context
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AccountTaskContext:
|
|
task_name: str
|
|
username: str
|
|
started: datetime
|
|
extra: dict[str, Any] | None = None
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PasswordResetRequest:
|
|
task_name: str
|
|
service_label: str
|
|
username: str
|
|
mailu_email: str
|
|
password: str
|
|
sync_fn: Callable[[], dict[str, Any]]
|
|
password_attr: str
|
|
updated_attr: str
|
|
error_hint: str
|
|
|
|
|
|
def _resolve_mailu_email(module: Any, username: str) -> str:
|
|
mailu_email = f"{username}@{module.settings.mailu_domain}"
|
|
try:
|
|
user = module.keycloak_admin.find_user(username) or {}
|
|
attrs = user.get("attributes") if isinstance(user, dict) else None
|
|
if isinstance(attrs, dict):
|
|
raw_mailu = attrs.get("mailu_email")
|
|
if isinstance(raw_mailu, list) and raw_mailu:
|
|
return str(raw_mailu[0])
|
|
if isinstance(raw_mailu, str) and raw_mailu:
|
|
return raw_mailu
|
|
except Exception:
|
|
return mailu_email
|
|
return mailu_email
|
|
|
|
|
|
def _record_account_task(module: Any, ctx: AccountTaskContext, status: str, error_detail: str) -> None:
|
|
finished = datetime.now(timezone.utc)
|
|
duration_sec = (finished - ctx.started).total_seconds()
|
|
module.record_task_run(ctx.task_name, status, duration_sec)
|
|
try:
|
|
module.storage.record_task_run(
|
|
TaskRunRecord(
|
|
request_code=None,
|
|
task=ctx.task_name,
|
|
status=status,
|
|
detail=error_detail or None,
|
|
started_at=ctx.started,
|
|
finished_at=finished,
|
|
duration_ms=int(duration_sec * 1000),
|
|
)
|
|
)
|
|
except Exception:
|
|
pass
|
|
detail = {"username": ctx.username, "status": status, "error": error_detail}
|
|
if ctx.extra:
|
|
detail.update(ctx.extra)
|
|
module._record_event(ctx.task_name, detail)
|
|
|
|
|
|
def _run_password_reset(module: Any, request: PasswordResetRequest) -> JSONResponse:
|
|
started = datetime.now(timezone.utc)
|
|
task_ctx = AccountTaskContext(
|
|
task_name=request.task_name,
|
|
username=request.username,
|
|
started=started,
|
|
extra={"mailu_email": request.mailu_email},
|
|
)
|
|
status = "ok"
|
|
error_detail = ""
|
|
module.logger.info(
|
|
f"{request.service_label} password reset requested",
|
|
extra={"event": request.task_name, "username": request.username},
|
|
)
|
|
try:
|
|
result = request.sync_fn()
|
|
status_val = result.get("status") if isinstance(result, dict) else "error"
|
|
if status_val != "ok":
|
|
raise RuntimeError(f"{request.service_label} sync {status_val}")
|
|
|
|
module.keycloak_admin.set_user_attribute(request.username, request.password_attr, request.password)
|
|
module.keycloak_admin.set_user_attribute(
|
|
request.username,
|
|
request.updated_attr,
|
|
datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
)
|
|
|
|
module.logger.info(
|
|
f"{request.service_label} password reset completed",
|
|
extra={"event": request.task_name, "username": request.username},
|
|
)
|
|
return JSONResponse({"status": "ok", "password": request.password})
|
|
except HTTPException as exc:
|
|
status = "error"
|
|
error_detail = str(exc.detail)
|
|
raise
|
|
except Exception as exc:
|
|
status = "error"
|
|
error_detail = safe_error_detail(exc, request.error_hint)
|
|
raise HTTPException(status_code=502, detail=error_detail)
|
|
finally:
|
|
_record_account_task(module, task_ctx, status, error_detail)
|
|
|
|
|
|
def _register_account_routes(app: FastAPI, require_auth: Callable, deps: Callable[[], Any]) -> None: # noqa: PLR0915
|
|
@app.post("/api/account/mailu/rotate")
|
|
def rotate_mailu_password(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
|
"""Rotate the caller's Mailu app password and trigger dependent syncs."""
|
|
|
|
module = deps()
|
|
module._require_account_access(ctx)
|
|
if not module.keycloak_admin.ready():
|
|
raise HTTPException(status_code=503, detail="server not configured")
|
|
|
|
username = ctx.username or ""
|
|
if not username:
|
|
raise HTTPException(status_code=400, detail="missing username")
|
|
with task_context("account.mailu_rotate"):
|
|
started = datetime.now(timezone.utc)
|
|
status = "ok"
|
|
error_detail = ""
|
|
sync_enabled = module.mailu.ready()
|
|
sync_ok = False
|
|
sync_error = ""
|
|
nextcloud_sync: dict[str, Any] = {"status": "skipped"}
|
|
|
|
module.logger.info("mailu password rotate requested", extra={"event": "mailu_rotate", "username": username})
|
|
try:
|
|
password = module.random_password()
|
|
module.keycloak_admin.set_user_attribute(username, "mailu_app_password", password)
|
|
|
|
if sync_enabled:
|
|
try:
|
|
module.mailu.sync("ariadne_mailu_rotate")
|
|
sync_ok = True
|
|
except Exception as exc:
|
|
sync_error = safe_error_detail(exc, "sync request failed")
|
|
|
|
try:
|
|
nextcloud_sync = module.nextcloud.sync_mail(username, wait=True)
|
|
except Exception as exc:
|
|
nextcloud_sync = {"status": "error", "detail": safe_error_detail(exc, "failed to sync nextcloud")}
|
|
|
|
module.logger.info(
|
|
"mailu password rotate completed",
|
|
extra={
|
|
"event": "mailu_rotate",
|
|
"username": username,
|
|
"sync_enabled": sync_enabled,
|
|
"sync_ok": sync_ok,
|
|
"nextcloud_status": nextcloud_sync.get("status") if isinstance(nextcloud_sync, dict) else "",
|
|
},
|
|
)
|
|
return JSONResponse(
|
|
{
|
|
"password": password,
|
|
"sync_enabled": sync_enabled,
|
|
"sync_ok": sync_ok,
|
|
"sync_error": sync_error,
|
|
"nextcloud_sync": nextcloud_sync,
|
|
}
|
|
)
|
|
except HTTPException as exc:
|
|
status = "error"
|
|
error_detail = str(exc.detail)
|
|
raise
|
|
except Exception as exc:
|
|
status = "error"
|
|
error_detail = safe_error_detail(exc, "mailu rotate failed")
|
|
raise HTTPException(status_code=502, detail=error_detail)
|
|
finally:
|
|
task_ctx = AccountTaskContext("mailu_rotate", username, started)
|
|
_record_account_task(module, task_ctx, status, error_detail)
|
|
module._record_event(
|
|
"mailu_rotate",
|
|
{
|
|
"username": username,
|
|
"status": status,
|
|
"sync_enabled": sync_enabled,
|
|
"sync_ok": sync_ok,
|
|
"nextcloud_status": nextcloud_sync.get("status") if isinstance(nextcloud_sync, dict) else "",
|
|
"error": error_detail,
|
|
},
|
|
)
|
|
|
|
@app.post("/api/account/wger/reset")
|
|
def reset_wger_password(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
|
"""Reset the caller's Wger password and synchronize the service account."""
|
|
|
|
module = deps()
|
|
module._require_account_access(ctx)
|
|
if not module.keycloak_admin.ready():
|
|
raise HTTPException(status_code=503, detail="server not configured")
|
|
|
|
username = ctx.username or ""
|
|
if not username:
|
|
raise HTTPException(status_code=400, detail="missing username")
|
|
|
|
with task_context("account.wger_reset"):
|
|
mailu_email = _resolve_mailu_email(module, username)
|
|
password = module.random_password()
|
|
request = PasswordResetRequest(
|
|
task_name="wger_reset",
|
|
service_label="wger",
|
|
username=username,
|
|
mailu_email=mailu_email,
|
|
password=password,
|
|
sync_fn=lambda: module.wger.sync_user(username, mailu_email, password, wait=True),
|
|
password_attr="wger_password",
|
|
updated_attr="wger_password_updated_at",
|
|
error_hint="wger sync failed",
|
|
)
|
|
return _run_password_reset(module, request)
|
|
|
|
@app.post("/api/account/firefly/reset")
|
|
def reset_firefly_password(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
|
"""Reset the caller's Firefly password and synchronize the service account."""
|
|
|
|
module = deps()
|
|
module._require_account_access(ctx)
|
|
if not module.keycloak_admin.ready():
|
|
raise HTTPException(status_code=503, detail="server not configured")
|
|
|
|
username = ctx.username or ""
|
|
if not username:
|
|
raise HTTPException(status_code=400, detail="missing username")
|
|
|
|
with task_context("account.firefly_reset"):
|
|
mailu_email = _resolve_mailu_email(module, username)
|
|
password = module.random_password(24)
|
|
request = PasswordResetRequest(
|
|
task_name="firefly_reset",
|
|
service_label="firefly",
|
|
username=username,
|
|
mailu_email=mailu_email,
|
|
password=password,
|
|
sync_fn=lambda: module.firefly.sync_user(mailu_email, password, wait=True),
|
|
password_attr="firefly_password",
|
|
updated_attr="firefly_password_updated_at",
|
|
error_hint="firefly sync failed",
|
|
)
|
|
return _run_password_reset(module, request)
|
|
|
|
@app.post("/api/account/firefly/rotation/check")
|
|
def firefly_rotation_check(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
|
"""Check whether the caller's Firefly password rotation is healthy."""
|
|
|
|
module = deps()
|
|
module._require_account_access(ctx)
|
|
if not module.keycloak_admin.ready():
|
|
raise HTTPException(status_code=503, detail="server not configured")
|
|
|
|
username = ctx.username or ""
|
|
if not username:
|
|
raise HTTPException(status_code=400, detail="missing username")
|
|
|
|
with task_context("account.firefly_rotation_check"):
|
|
result = module.firefly.check_rotation_for_user(username)
|
|
if result.get("status") == "error":
|
|
raise HTTPException(status_code=502, detail=result.get("detail") or "firefly rotation check failed")
|
|
return JSONResponse(result)
|
|
|
|
@app.post("/api/account/wger/rotation/check")
|
|
def wger_rotation_check(ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
|
"""Check whether the caller's Wger password rotation is healthy."""
|
|
|
|
module = deps()
|
|
module._require_account_access(ctx)
|
|
if not module.keycloak_admin.ready():
|
|
raise HTTPException(status_code=503, detail="server not configured")
|
|
|
|
username = ctx.username or ""
|
|
if not username:
|
|
raise HTTPException(status_code=400, detail="missing username")
|
|
|
|
with task_context("account.wger_rotation_check"):
|
|
result = module.wger.check_rotation_for_user(username)
|
|
if result.get("status") == "error":
|
|
raise HTTPException(status_code=502, detail=result.get("detail") or "wger rotation check failed")
|
|
return JSONResponse(result)
|
|
|
|
@app.post("/api/account/nextcloud/mail/sync")
|
|
async def nextcloud_mail_sync(request: Request, ctx: AuthContext = Depends(require_auth)) -> JSONResponse:
|
|
"""Synchronize the caller's Mailu address into Nextcloud mail settings."""
|
|
|
|
module = deps()
|
|
module._require_account_access(ctx)
|
|
if not module.keycloak_admin.ready():
|
|
raise HTTPException(status_code=503, detail="server not configured")
|
|
|
|
username = ctx.username or ""
|
|
if not username:
|
|
raise HTTPException(status_code=400, detail="missing username")
|
|
|
|
with task_context("account.nextcloud_sync"):
|
|
try:
|
|
payload = await request.json()
|
|
except Exception:
|
|
payload = {}
|
|
wait = bool(payload.get("wait", True)) if isinstance(payload, dict) else True
|
|
|
|
started = datetime.now(timezone.utc)
|
|
status = "ok"
|
|
error_detail = ""
|
|
module.logger.info("nextcloud mail sync requested", extra={"event": "nextcloud_sync", "username": username, "wait": wait})
|
|
try:
|
|
result = module.nextcloud.sync_mail(username, wait=wait)
|
|
module.logger.info(
|
|
"nextcloud mail sync completed",
|
|
extra={
|
|
"event": "nextcloud_sync",
|
|
"username": username,
|
|
"status": result.get("status") if isinstance(result, dict) else "",
|
|
},
|
|
)
|
|
return JSONResponse(result)
|
|
except HTTPException as exc:
|
|
status = "error"
|
|
error_detail = str(exc.detail)
|
|
raise
|
|
except Exception as exc:
|
|
status = "error"
|
|
error_detail = safe_error_detail(exc, "failed to sync nextcloud mail")
|
|
module.logger.info(
|
|
"nextcloud mail sync failed",
|
|
extra={"event": "nextcloud_sync", "username": username, "error": error_detail},
|
|
)
|
|
raise HTTPException(status_code=502, detail=error_detail)
|
|
finally:
|
|
task_ctx = AccountTaskContext("nextcloud_sync", username, started)
|
|
_record_account_task(module, task_ctx, status, error_detail)
|
|
module._record_event(
|
|
"nextcloud_sync",
|
|
{
|
|
"username": username,
|
|
"status": status,
|
|
"wait": wait,
|
|
"error": error_detail,
|
|
},
|
|
)
|