624 lines
20 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import textwrap
from ..k8s.exec import ExecError, PodExecutor
from ..k8s.pods import PodSelectionError
from ..settings import settings
from ..utils.logging import get_logger
from ..utils.passwords import random_password
from .keycloak_admin import keycloak_admin
from .mailu import mailu
EXIT_PASSWORD_MATCH = 0
EXIT_PASSWORD_MISMATCH = 1
EXIT_USER_MISSING = 3
WGER_PASSWORD_ATTR = "wger_password"
WGER_PASSWORD_UPDATED_ATTR = "wger_password_updated_at"
WGER_PASSWORD_ROTATED_ATTR = "wger_password_rotated_at"
logger = get_logger(__name__)
_WGER_SYNC_SCRIPT = textwrap.dedent(
"""
from __future__ import annotations
import os
import sys
import django
def _env(name: str, default: str = "") -> str:
value = os.getenv(name, default)
return value.strip() if isinstance(value, str) else ""
def _setup_django() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.main")
django.setup()
def _set_default_gym(user) -> None:
try:
from wger.gym.models import GymConfig
except Exception:
return
try:
config = GymConfig.objects.first()
except Exception:
return
if not config or not getattr(config, "default_gym", None):
return
profile = getattr(user, "userprofile", None)
if not profile or getattr(profile, "gym", None):
return
profile.gym = config.default_gym
profile.save()
def _ensure_profile(user) -> None:
profile = getattr(user, "userprofile", None)
if not profile:
return
if hasattr(profile, "email_verified") and not profile.email_verified:
profile.email_verified = True
if hasattr(profile, "is_temporary") and profile.is_temporary:
profile.is_temporary = False
profile.save()
def _ensure_admin(username: str, password: str, email: str) -> None:
from django.contrib.auth.models import User
if not username or not password:
raise RuntimeError("admin username/password missing")
user, created = User.objects.get_or_create(username=username)
if created:
user.is_active = True
if not user.is_staff:
user.is_staff = True
if email:
user.email = email
user.set_password(password)
user.save()
_ensure_profile(user)
_set_default_gym(user)
print(f"ensured admin user {username}")
def _ensure_user(username: str, password: str, email: str) -> None:
from django.contrib.auth.models import User
if not username or not password:
raise RuntimeError("username/password missing")
user, created = User.objects.get_or_create(username=username)
if created:
user.is_active = True
if email and user.email != email:
user.email = email
user.set_password(password)
user.save()
_ensure_profile(user)
_set_default_gym(user)
action = "created" if created else "updated"
print(f"{action} user {username}")
def main() -> int:
admin_user = _env("WGER_ADMIN_USERNAME")
admin_password = _env("WGER_ADMIN_PASSWORD")
admin_email = _env("WGER_ADMIN_EMAIL")
username = _env("WGER_USERNAME") or _env("ONLY_USERNAME")
password = _env("WGER_PASSWORD")
email = _env("WGER_EMAIL")
if not any([admin_user and admin_password, username and password]):
print("no admin or user payload provided; exiting")
return 0
_setup_django()
if admin_user and admin_password:
_ensure_admin(admin_user, admin_password, admin_email)
if username and password:
_ensure_user(username, password, email)
return 0
if __name__ == "__main__":
sys.exit(main())
"""
).strip()
_WGER_PASSWORD_CHECK_SCRIPT = textwrap.dedent(
"""
from __future__ import annotations
import os
import sys
import django
def _env(name: str, default: str = "") -> str:
value = os.getenv(name, default)
return value.strip() if isinstance(value, str) else ""
def _setup_django() -> None:
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings.main")
django.setup()
def main() -> int:
username = _env("WGER_USERNAME")
password = _env("WGER_PASSWORD")
if not username or not password:
print("missing username or password")
return 2
_setup_django()
from django.contrib.auth.models import User
user = User.objects.filter(username=username).first()
if not user:
print(f"user {username} missing")
return 3
if user.check_password(password):
print("password match")
return 0
print("password mismatch")
return 1
if __name__ == "__main__":
sys.exit(main())
"""
).strip()
def _wger_exec_command() -> str:
bootstrap = ". /vault/secrets/wger-env >/dev/null 2>&1 || true"
return f"{bootstrap}\npython3 - <<'PY'\n{_WGER_SYNC_SCRIPT}\nPY"
def _wger_check_command() -> str:
bootstrap = ". /vault/secrets/wger-env >/dev/null 2>&1 || true"
return f"{bootstrap}\npython3 - <<'PY'\n{_WGER_PASSWORD_CHECK_SCRIPT}\nPY"
@dataclass(frozen=True)
class WgerSyncSummary:
processed: int
synced: int
skipped: int
failures: int
detail: str = ""
@dataclass
class WgerSyncCounters:
processed: int = 0
synced: int = 0
skipped: int = 0
failures: int = 0
def status(self) -> str:
return "ok" if self.failures == 0 else "error"
def summary(self, detail: str = "") -> WgerSyncSummary:
return WgerSyncSummary(
processed=self.processed,
synced=self.synced,
skipped=self.skipped,
failures=self.failures,
detail=detail,
)
@dataclass(frozen=True)
class UserSyncOutcome:
status: str
detail: str = ""
@dataclass(frozen=True)
class UserIdentity:
username: str
user_id: str
@dataclass(frozen=True)
class WgerSyncInput:
username: str
mailu_email: str
password: str
password_generated: bool
updated_at: str
rotated_at: str
def _extract_attr(attrs: Any, key: str) -> str:
if not isinstance(attrs, dict):
return ""
raw = attrs.get(key)
if isinstance(raw, list):
for item in raw:
if isinstance(item, str) and item.strip():
return item.strip()
return ""
if isinstance(raw, str) and raw.strip():
return raw.strip()
return ""
def _should_skip_user(user: dict[str, Any], username: str) -> bool:
if not username or user.get("enabled") is False:
return True
if user.get("serviceAccountClientId") or username.startswith("service-account-"):
return True
return False
def _load_attrs(user_id: str, user: dict[str, Any]) -> dict[str, Any] | None:
attrs = user.get("attributes")
if isinstance(attrs, dict):
return attrs
try:
full = keycloak_admin.get_user(user_id)
except Exception:
return None
attrs = full.get("attributes") if isinstance(full, dict) else {}
return attrs if isinstance(attrs, dict) else {}
def _ensure_mailu_email(username: str, attrs: dict[str, Any], fallback_email: str) -> str | None:
mailu_email = mailu.resolve_mailu_email(username, attrs, fallback_email)
if not mailu_email:
return None
if _extract_attr(attrs, "mailu_email"):
return mailu_email
try:
keycloak_admin.set_user_attribute(username, "mailu_email", mailu_email)
except Exception:
return None
return mailu_email
def _ensure_wger_password(username: str, attrs: dict[str, Any]) -> tuple[str | None, bool]:
password = _extract_attr(attrs, WGER_PASSWORD_ATTR)
if password:
return password, False
password = random_password(20)
try:
keycloak_admin.set_user_attribute(username, WGER_PASSWORD_ATTR, password)
except Exception:
return None, False
return password, True
def _set_wger_updated_at(username: str) -> bool:
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
try:
keycloak_admin.set_user_attribute(username, WGER_PASSWORD_UPDATED_ATTR, now_iso)
except Exception:
return False
return True
def _set_wger_rotated_at(username: str) -> bool:
now_iso = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
try:
keycloak_admin.set_user_attribute(username, WGER_PASSWORD_ROTATED_ATTR, now_iso)
except Exception:
return False
return True
def _normalize_user(user: dict[str, Any]) -> tuple[UserSyncOutcome | None, UserIdentity | None]:
username = user.get("username") if isinstance(user.get("username"), str) else ""
if _should_skip_user(user, username):
return UserSyncOutcome("skipped"), None
user_id = user.get("id") if isinstance(user.get("id"), str) else ""
if not user_id:
return UserSyncOutcome("failed", "missing user id"), None
return None, UserIdentity(username, user_id)
def _load_attrs_or_outcome(
identity: UserIdentity,
user: dict[str, Any],
) -> tuple[dict[str, Any] | None, UserSyncOutcome | None]:
attrs = _load_attrs(identity.user_id, user)
if attrs is None:
return None, UserSyncOutcome("failed", "missing attributes")
return attrs, None
def _mailu_email_or_outcome(
identity: UserIdentity,
attrs: dict[str, Any],
user: dict[str, Any],
) -> tuple[str | None, UserSyncOutcome | None]:
mailu_email = _ensure_mailu_email(
identity.username,
attrs,
user.get("email") if isinstance(user.get("email"), str) else "",
)
if not mailu_email:
return None, UserSyncOutcome("failed", "missing mailu email")
return mailu_email, None
def _wger_password_or_outcome(
identity: UserIdentity,
attrs: dict[str, Any],
) -> tuple[str | None, bool, UserSyncOutcome | None]:
password, generated = _ensure_wger_password(identity.username, attrs)
if not password:
return None, generated, UserSyncOutcome("failed", "missing wger password")
return password, generated, None
def _should_skip_sync(password_generated: bool, updated_at: str) -> bool:
return not password_generated and bool(updated_at)
def _build_sync_input(user: dict[str, Any]) -> WgerSyncInput | UserSyncOutcome:
outcome, identity = _normalize_user(user)
attrs = None
mailu_email = None
password = None
password_generated = False
if outcome is None and identity is not None:
attrs, outcome = _load_attrs_or_outcome(identity, user)
if outcome is None and attrs is not None:
mailu_email, outcome = _mailu_email_or_outcome(identity, attrs, user)
if outcome is None and mailu_email:
password, password_generated, outcome = _wger_password_or_outcome(identity, attrs)
if outcome:
return outcome
if identity is None:
return UserSyncOutcome("failed", "missing identity")
if attrs is None:
return UserSyncOutcome("failed", "missing attributes")
if not mailu_email:
return UserSyncOutcome("failed", "missing mailu email")
if not password:
return UserSyncOutcome("failed", "missing wger password")
updated_at = _extract_attr(attrs, WGER_PASSWORD_UPDATED_ATTR)
rotated_at = _extract_attr(attrs, WGER_PASSWORD_ROTATED_ATTR)
return WgerSyncInput(
username=identity.username,
mailu_email=mailu_email,
password=password,
password_generated=password_generated,
updated_at=updated_at,
rotated_at=rotated_at,
)
def _rotation_result(status: str, detail: str = "", rotated: bool | None = None) -> dict[str, Any]:
result = {"status": status}
if status == "ok":
result["rotated"] = bool(rotated)
elif detail:
result["detail"] = detail
return result
def _rotation_check_input(username: str) -> tuple[WgerSyncInput | UserSyncOutcome | None, str]:
if not username:
return None, "missing username"
if not keycloak_admin.ready():
return None, "keycloak admin not configured"
user = keycloak_admin.find_user(username)
if not isinstance(user, dict):
return None, "user not found"
user_id = user.get("id") if isinstance(user.get("id"), str) else ""
if not user_id:
return None, "missing user id"
full = keycloak_admin.get_user(user_id)
return _build_sync_input(full), ""
class WgerService:
def __init__(self) -> None:
self._executor = PodExecutor(
settings.wger_namespace,
settings.wger_pod_label,
settings.wger_container,
)
def check_rotation_for_user(self, username: str) -> dict[str, Any]:
cleaned = (username or "").strip()
prepared, error = _rotation_check_input(cleaned)
if error:
return _rotation_result("error", error)
if isinstance(prepared, UserSyncOutcome):
if prepared.status == "skipped":
return _rotation_result("ok", rotated=False)
return _rotation_result("error", prepared.detail or "")
outcome = self._rotation_outcome(prepared)
if outcome.status == "synced":
return _rotation_result("ok", rotated=True)
if outcome.status == "skipped":
return _rotation_result("ok", rotated=False)
return _rotation_result("error", outcome.detail or "rotation check failed")
def sync_user(self, username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]:
username = (username or "").strip()
if not username:
raise RuntimeError("missing username")
if not password:
raise RuntimeError("missing password")
if not settings.wger_namespace:
raise RuntimeError("wger sync not configured")
env = {
"WGER_USERNAME": username,
"WGER_EMAIL": email,
"WGER_PASSWORD": password,
}
try:
result = self._executor.exec(
_wger_exec_command(),
env=env,
timeout_sec=settings.wger_user_sync_wait_timeout_sec,
check=True,
)
except (ExecError, PodSelectionError, TimeoutError) as exc:
return {"status": "error", "detail": str(exc)}
output = (result.stdout or result.stderr).strip()
return {"status": "ok", "detail": output}
def check_password(self, username: str, password: str) -> dict[str, Any]:
username = (username or "").strip()
if not username:
raise RuntimeError("missing username")
if not password:
raise RuntimeError("missing password")
if not settings.wger_namespace:
raise RuntimeError("wger sync not configured")
env = {
"WGER_USERNAME": username,
"WGER_PASSWORD": password,
}
try:
result = self._executor.exec(
_wger_check_command(),
env=env,
timeout_sec=settings.wger_user_sync_wait_timeout_sec,
check=False,
)
except (ExecError, PodSelectionError, TimeoutError) as exc:
return {"status": "error", "detail": str(exc)}
detail = (result.stdout or result.stderr).strip()
if result.exit_code == EXIT_PASSWORD_MATCH:
return {"status": "match", "detail": detail}
if result.exit_code == EXIT_PASSWORD_MISMATCH:
return {"status": "mismatch", "detail": detail}
if result.exit_code == EXIT_USER_MISSING:
return {"status": "missing", "detail": detail or "user missing"}
return {"status": "error", "detail": detail or f"exit_code={result.exit_code}"}
def _rotation_outcome(self, prepared: WgerSyncInput) -> UserSyncOutcome:
if prepared.rotated_at:
return UserSyncOutcome("skipped")
check = self.check_password(prepared.username, prepared.password)
status = check.get("status") if isinstance(check, dict) else "error"
if status == "match":
return UserSyncOutcome("skipped")
if status == "mismatch":
if not _set_wger_rotated_at(prepared.username):
return UserSyncOutcome("failed", "failed to set rotated_at")
return UserSyncOutcome("synced")
detail = check.get("detail") if isinstance(check, dict) else ""
detail = detail or "password check failed"
return UserSyncOutcome("failed", detail)
def ensure_admin(self, wait: bool = False) -> dict[str, Any]:
if not settings.wger_namespace:
raise RuntimeError("wger admin sync not configured")
if not settings.wger_admin_username or not settings.wger_admin_password:
return {"status": "error", "detail": "admin credentials missing"}
env = {
"WGER_ADMIN_USERNAME": settings.wger_admin_username,
"WGER_ADMIN_PASSWORD": settings.wger_admin_password,
"WGER_ADMIN_EMAIL": settings.wger_admin_email,
}
try:
result = self._executor.exec(
_wger_exec_command(),
env=env,
timeout_sec=settings.wger_user_sync_wait_timeout_sec,
check=True,
)
except (ExecError, PodSelectionError, TimeoutError) as exc:
return {"status": "error", "detail": str(exc)}
output = (result.stdout or result.stderr).strip()
return {"status": "ok", "detail": output}
def _sync_user_entry(self, user: dict[str, Any]) -> UserSyncOutcome:
prepared = _build_sync_input(user)
if isinstance(prepared, UserSyncOutcome):
return prepared
if _should_skip_sync(prepared.password_generated, prepared.updated_at):
return self._rotation_outcome(prepared)
result = self.sync_user(prepared.username, prepared.mailu_email, prepared.password, wait=True)
result_status = result.get("status") if isinstance(result, dict) else "error"
if result_status != "ok":
detail = result.get("detail") if isinstance(result, dict) else ""
detail = detail or f"sync {result_status}"
return UserSyncOutcome("failed", detail)
if not _set_wger_updated_at(prepared.username):
return UserSyncOutcome("failed", "failed to set updated_at")
return UserSyncOutcome("synced")
def sync_users(self) -> dict[str, Any]:
if not keycloak_admin.ready():
return {"status": "error", "detail": "keycloak admin not configured"}
if not settings.wger_namespace:
raise RuntimeError("wger sync not configured")
counters = WgerSyncCounters()
users = keycloak_admin.iter_users(page_size=200, brief=False)
for user in users:
outcome = self._sync_user_entry(user)
if outcome.status == "synced":
counters.processed += 1
counters.synced += 1
elif outcome.status == "skipped":
counters.skipped += 1
else:
counters.failures += 1
summary = counters.summary()
logger.info(
"wger user sync finished",
extra={
"event": "wger_user_sync",
"status": counters.status(),
"processed": summary.processed,
"synced": summary.synced,
"skipped": summary.skipped,
"failures": summary.failures,
},
)
return {"status": counters.status(), "summary": summary}
wger = WgerService()