624 lines
20 KiB
Python
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()
|