ariadne/ariadne/services/firefly.py

667 lines
21 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any
import textwrap
import httpx
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
HTTP_OK = 200
EXIT_PASSWORD_MATCH = 0
EXIT_PASSWORD_MISMATCH = 1
EXIT_USER_MISSING = 3
FIREFLY_PASSWORD_ATTR = "firefly_password"
FIREFLY_PASSWORD_UPDATED_ATTR = "firefly_password_updated_at"
FIREFLY_PASSWORD_ROTATED_ATTR = "firefly_password_rotated_at"
logger = get_logger(__name__)
_FIREFLY_SYNC_SCRIPT = textwrap.dedent(
"""
<?php
declare(strict_types=1);
use FireflyIII\\Console\\Commands\\Correction\\CreatesGroupMemberships;
use FireflyIII\\Models\\Role;
use FireflyIII\\Repositories\\User\\UserRepositoryInterface;
use FireflyIII\\Support\\Facades\\FireflyConfig;
use FireflyIII\\User;
use Illuminate\\Contracts\\Console\\Kernel as ConsoleKernel;
function log_line(string $message): void
{
fwrite(STDOUT, $message . PHP_EOL);
}
function error_line(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
function find_app_root(): string
{
$candidates = [];
$env_root = getenv('FIREFLY_APP_DIR') ?: '';
if ($env_root !== '') {
$candidates[] = $env_root;
}
$candidates[] = '/var/www/html';
$candidates[] = '/var/www/firefly-iii';
$candidates[] = '/app';
foreach ($candidates as $candidate) {
if (!is_dir($candidate)) {
continue;
}
if (file_exists($candidate . '/vendor/autoload.php')) {
return $candidate;
}
}
return '';
}
$email = trim((string) getenv('FIREFLY_USER_EMAIL'));
$password = (string) getenv('FIREFLY_USER_PASSWORD');
if ($email === '' || $password === '') {
error_line('missing FIREFLY_USER_EMAIL or FIREFLY_USER_PASSWORD');
exit(1);
}
$root = find_app_root();
if ($root === '') {
error_line('firefly app root not found');
exit(1);
}
$autoload = $root . '/vendor/autoload.php';
$app_bootstrap = $root . '/bootstrap/app.php';
if (!file_exists($autoload) || !file_exists($app_bootstrap)) {
error_line('firefly bootstrap files missing');
exit(1);
}
require $autoload;
$app = require $app_bootstrap;
$kernel = $app->make(ConsoleKernel::class);
$kernel->bootstrap();
try {
FireflyConfig::set('single_user_mode', true);
} catch (Throwable $exc) {
error_line('failed to enforce single_user_mode: ' . $exc->getMessage());
}
$repository = $app->make(UserRepositoryInterface::class);
$existing_user = User::where('email', $email)->first();
$first_user = User::count() == 0;
if (!$existing_user) {
$existing_user = User::create(
[
'email' => $email,
'password' => bcrypt($password),
'blocked' => false,
'blocked_code' => null,
]
);
if ($first_user) {
$role = Role::where('name', 'owner')->first();
if ($role) {
$existing_user->roles()->attach($role);
}
}
log_line(sprintf('created firefly user %s', $email));
} else {
log_line(sprintf('updating firefly user %s', $email));
}
$existing_user->blocked = false;
$existing_user->blocked_code = null;
$existing_user->save();
$repository->changePassword($existing_user, $password);
CreatesGroupMemberships::createGroupMembership($existing_user);
log_line('firefly user sync complete');
"""
).strip()
_FIREFLY_PASSWORD_CHECK_SCRIPT = textwrap.dedent(
"""
<?php
declare(strict_types=1);
use FireflyIII\\Support\\Facades\\FireflyConfig;
use FireflyIII\\User;
use Illuminate\\Contracts\\Console\\Kernel as ConsoleKernel;
use Illuminate\\Support\\Facades\\Hash;
function log_line(string $message): void
{
fwrite(STDOUT, $message . PHP_EOL);
}
function error_line(string $message): void
{
fwrite(STDERR, $message . PHP_EOL);
}
function find_app_root(): string
{
$candidates = [];
$env_root = getenv('FIREFLY_APP_DIR') ?: '';
if ($env_root !== '') {
$candidates[] = $env_root;
}
$candidates[] = '/var/www/html';
$candidates[] = '/var/www/firefly-iii';
$candidates[] = '/app';
foreach ($candidates as $candidate) {
if (!is_dir($candidate)) {
continue;
}
if (file_exists($candidate . '/vendor/autoload.php')) {
return $candidate;
}
}
return '';
}
$email = trim((string) getenv('FIREFLY_USER_EMAIL'));
$username = trim((string) getenv('FIREFLY_USER_USERNAME'));
$password = (string) getenv('FIREFLY_USER_PASSWORD');
if (($email === '' && $username === '') || $password === '') {
error_line('missing FIREFLY_USER_EMAIL or FIREFLY_USER_USERNAME or FIREFLY_USER_PASSWORD');
exit(2);
}
$root = find_app_root();
if ($root === '') {
error_line('firefly app root not found');
exit(2);
}
$autoload = $root . '/vendor/autoload.php';
$app_bootstrap = $root . '/bootstrap/app.php';
if (!file_exists($autoload) || !file_exists($app_bootstrap)) {
error_line('firefly bootstrap files missing');
exit(2);
}
require $autoload;
$app = require $app_bootstrap;
$kernel = $app->make(ConsoleKernel::class);
$kernel->bootstrap();
try {
FireflyConfig::set('single_user_mode', true);
} catch (Throwable $exc) {
error_line('failed to enforce single_user_mode: ' . $exc->getMessage());
}
if ($email !== '') {
$query = User::where('email', $email);
} else {
$query = User::where('username', $username);
}
if ($email !== '' && $username !== '') {
$query = $query->orWhere('username', $username);
}
$existing_user = $query->first();
if (!$existing_user) {
error_line('firefly user missing');
exit(3);
}
if (Hash::check($password, $existing_user->password)) {
log_line('password match');
exit(0);
}
log_line('password mismatch');
exit(1);
"""
).strip()
def _firefly_exec_command() -> str:
return f"php <<'PHP'\n{_FIREFLY_SYNC_SCRIPT}\nPHP"
def _firefly_check_command() -> str:
return f"php <<'PHP'\n{_FIREFLY_PASSWORD_CHECK_SCRIPT}\nPHP"
@dataclass(frozen=True)
class FireflySyncSummary:
processed: int
synced: int
skipped: int
failures: int
detail: str = ""
@dataclass
class FireflySyncCounters:
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 = "") -> FireflySyncSummary:
return FireflySyncSummary(
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 FireflySyncInput:
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_firefly_password(username: str, attrs: dict[str, Any]) -> tuple[str | None, bool]:
password = _extract_attr(attrs, FIREFLY_PASSWORD_ATTR)
if password:
return password, False
password = random_password(24)
try:
keycloak_admin.set_user_attribute(username, FIREFLY_PASSWORD_ATTR, password)
except Exception:
return None, False
return password, True
def _set_firefly_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, FIREFLY_PASSWORD_UPDATED_ATTR, now_iso)
except Exception:
return False
return True
def _set_firefly_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, FIREFLY_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 _firefly_password_or_outcome(
identity: UserIdentity,
attrs: dict[str, Any],
) -> tuple[str | None, bool, UserSyncOutcome | None]:
password, generated = _ensure_firefly_password(identity.username, attrs)
if not password:
return None, generated, UserSyncOutcome("failed", "missing firefly 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]) -> FireflySyncInput | 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 = _firefly_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 firefly password")
updated_at = _extract_attr(attrs, FIREFLY_PASSWORD_UPDATED_ATTR)
rotated_at = _extract_attr(attrs, FIREFLY_PASSWORD_ROTATED_ATTR)
return FireflySyncInput(
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[FireflySyncInput | 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 FireflyService:
def __init__(self) -> None:
self._executor = PodExecutor(
settings.firefly_namespace,
settings.firefly_pod_label,
settings.firefly_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, email: str, password: str, wait: bool = True) -> dict[str, Any]:
email = (email or "").strip()
if not email:
raise RuntimeError("missing email")
if not password:
raise RuntimeError("missing password")
if not settings.firefly_namespace:
raise RuntimeError("firefly sync not configured")
env = {
"FIREFLY_USER_EMAIL": email,
"FIREFLY_USER_PASSWORD": password,
}
try:
result = self._executor.exec(
_firefly_exec_command(),
env=env,
timeout_sec=settings.firefly_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, email: str, password: str, username: str = "") -> dict[str, Any]:
email = (email or "").strip()
username = (username or "").strip()
if not email:
if not username:
raise RuntimeError("missing email")
if not password:
raise RuntimeError("missing password")
if not settings.firefly_namespace:
raise RuntimeError("firefly sync not configured")
env = {
"FIREFLY_USER_EMAIL": email,
"FIREFLY_USER_USERNAME": username,
"FIREFLY_USER_PASSWORD": password,
}
try:
result = self._executor.exec(
_firefly_check_command(),
env=env,
timeout_sec=settings.firefly_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: FireflySyncInput) -> UserSyncOutcome:
if prepared.rotated_at:
return UserSyncOutcome("skipped")
check = self.check_password(prepared.mailu_email, prepared.password, prepared.username)
status = check.get("status") if isinstance(check, dict) else "error"
if status == "match":
return UserSyncOutcome("skipped")
if status == "mismatch":
if not _set_firefly_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 run_cron(self) -> dict[str, Any]:
if not settings.firefly_cron_token:
raise RuntimeError("firefly cron token missing")
url = f"{settings.firefly_cron_base_url.rstrip('/')}/{settings.firefly_cron_token}"
try:
with httpx.Client(timeout=settings.firefly_cron_timeout_sec) as client:
resp = client.get(url)
if resp.status_code != HTTP_OK:
return {"status": "error", "detail": f"status={resp.status_code}"}
except Exception as exc:
return {"status": "error", "detail": str(exc)}
return {"status": "ok", "detail": "cron triggered"}
def _sync_user_entry(self, user: dict[str, Any]) -> UserSyncOutcome:
prepared = _build_sync_input(user)
if isinstance(prepared, UserSyncOutcome):
return prepared
outcome = None
if _should_skip_sync(prepared.password_generated, prepared.updated_at):
outcome = self._rotation_outcome(prepared)
if outcome is None:
result = self.sync_user(prepared.mailu_email, prepared.password, wait=True)
result_status = result.get("status") if isinstance(result, dict) else "error"
if result_status != "ok":
outcome = UserSyncOutcome("failed", f"sync {result_status}")
elif not _set_firefly_updated_at(prepared.username):
outcome = UserSyncOutcome("failed", "failed to set updated_at")
else:
outcome = UserSyncOutcome("synced")
return outcome
def sync_users(self) -> dict[str, Any]:
if not keycloak_admin.ready():
return {"status": "error", "detail": "keycloak admin not configured"}
if not settings.firefly_namespace:
raise RuntimeError("firefly sync not configured")
counters = FireflySyncCounters()
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(
"firefly user sync finished",
extra={
"event": "firefly_user_sync",
"status": counters.status(),
"processed": summary.processed,
"synced": summary.synced,
"skipped": summary.skipped,
"failures": summary.failures,
},
)
return {"status": counters.status(), "summary": summary}
firefly = FireflyService()