docs(bstein-home): document full source gate surface
This commit is contained in:
parent
839c2586a2
commit
2f703005fc
@ -13,12 +13,16 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class AriadneError(Exception):
|
class AriadneError(Exception):
|
||||||
|
"""Carry an upstream-facing error message and HTTP status code."""
|
||||||
|
|
||||||
def __init__(self, message: str, status_code: int = 502) -> None:
|
def __init__(self, message: str, status_code: int = 502) -> None:
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
|
|
||||||
|
|
||||||
def enabled() -> bool:
|
def enabled() -> bool:
|
||||||
|
"""Return whether Ariadne proxying is configured for this portal."""
|
||||||
|
|
||||||
return bool(settings.ARIADNE_URL)
|
return bool(settings.ARIADNE_URL)
|
||||||
|
|
||||||
|
|
||||||
@ -40,6 +44,12 @@ def request_raw(
|
|||||||
payload: Any | None = None,
|
payload: Any | None = None,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
) -> httpx.Response:
|
) -> httpx.Response:
|
||||||
|
"""Send one authenticated request to Ariadne and return the raw response.
|
||||||
|
|
||||||
|
WHY: callers need the exact upstream status/body so local routes can act as
|
||||||
|
a transparent compatibility proxy while still applying retry telemetry.
|
||||||
|
"""
|
||||||
|
|
||||||
if not enabled():
|
if not enabled():
|
||||||
raise AriadneError("ariadne not configured", 503)
|
raise AriadneError("ariadne not configured", 503)
|
||||||
|
|
||||||
@ -84,6 +94,12 @@ def proxy(
|
|||||||
payload: Any | None = None,
|
payload: Any | None = None,
|
||||||
params: dict[str, Any] | None = None,
|
params: dict[str, Any] | None = None,
|
||||||
) -> tuple[Any, int]:
|
) -> tuple[Any, int]:
|
||||||
|
"""Proxy an Ariadne response through Flask as JSON plus status code.
|
||||||
|
|
||||||
|
WHY: route handlers should share upstream error normalization instead of
|
||||||
|
duplicating JSON parsing and outage handling at each call site.
|
||||||
|
"""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = request_raw(method, path, payload=payload, params=params)
|
resp = request_raw(method, path, payload=payload, params=params)
|
||||||
except AriadneError as exc:
|
except AriadneError as exc:
|
||||||
|
|||||||
@ -15,10 +15,14 @@ _pool: ConnectionPool | None = None
|
|||||||
|
|
||||||
|
|
||||||
def configured() -> bool:
|
def configured() -> bool:
|
||||||
|
"""Return whether the portal has enough database configuration to connect."""
|
||||||
|
|
||||||
return bool(settings.PORTAL_DATABASE_URL)
|
return bool(settings.PORTAL_DATABASE_URL)
|
||||||
|
|
||||||
|
|
||||||
def _pool_kwargs() -> dict[str, Any]:
|
def _pool_kwargs() -> dict[str, Any]:
|
||||||
|
"""Build shared psycopg pool options for Atlas portal connections."""
|
||||||
|
|
||||||
options = (
|
options = (
|
||||||
f"-c lock_timeout={settings.PORTAL_DB_LOCK_TIMEOUT_SEC}s "
|
f"-c lock_timeout={settings.PORTAL_DB_LOCK_TIMEOUT_SEC}s "
|
||||||
f"-c statement_timeout={settings.PORTAL_DB_STATEMENT_TIMEOUT_SEC}s "
|
f"-c statement_timeout={settings.PORTAL_DB_STATEMENT_TIMEOUT_SEC}s "
|
||||||
@ -33,6 +37,8 @@ def _pool_kwargs() -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _get_pool() -> ConnectionPool:
|
def _get_pool() -> ConnectionPool:
|
||||||
|
"""Return the singleton Postgres connection pool for request handlers."""
|
||||||
|
|
||||||
global _pool
|
global _pool
|
||||||
if _pool is None:
|
if _pool is None:
|
||||||
if not settings.PORTAL_DATABASE_URL:
|
if not settings.PORTAL_DATABASE_URL:
|
||||||
@ -48,6 +54,8 @@ def _get_pool() -> ConnectionPool:
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def connect() -> Iterator[psycopg.Connection[Any]]:
|
def connect() -> Iterator[psycopg.Connection[Any]]:
|
||||||
|
"""Yield a dict-row Postgres connection from the shared pool."""
|
||||||
|
|
||||||
if not settings.PORTAL_DATABASE_URL:
|
if not settings.PORTAL_DATABASE_URL:
|
||||||
raise RuntimeError("portal database not configured")
|
raise RuntimeError("portal database not configured")
|
||||||
with _get_pool().connection() as conn:
|
with _get_pool().connection() as conn:
|
||||||
@ -70,6 +78,12 @@ def _release_advisory_lock(conn: psycopg.Connection[Any], lock_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def run_migrations() -> None:
|
def run_migrations() -> None:
|
||||||
|
"""Create and upgrade the portal schema using an advisory lock.
|
||||||
|
|
||||||
|
WHY: every replica may start concurrently, so schema changes must be safe
|
||||||
|
to run repeatedly without allowing multiple pods to migrate at once.
|
||||||
|
"""
|
||||||
|
|
||||||
if not settings.PORTAL_DATABASE_URL or not settings.PORTAL_RUN_MIGRATIONS:
|
if not settings.PORTAL_DATABASE_URL or not settings.PORTAL_RUN_MIGRATIONS:
|
||||||
return
|
return
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
@ -193,4 +207,6 @@ def run_migrations() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def ensure_schema() -> None:
|
def ensure_schema() -> None:
|
||||||
|
"""Run startup migrations through a small semantic wrapper."""
|
||||||
|
|
||||||
run_migrations()
|
run_migrations()
|
||||||
|
|||||||
@ -21,6 +21,8 @@ def _job_from_cronjob(
|
|||||||
email: str,
|
email: str,
|
||||||
password: str,
|
password: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Render a one-off Firefly user sync Job from the managed CronJob template."""
|
||||||
|
|
||||||
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
|
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
|
||||||
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
|
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
|
||||||
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
|
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
|
||||||
@ -70,6 +72,8 @@ def _job_from_cronjob(
|
|||||||
|
|
||||||
|
|
||||||
def _job_succeeded(job: dict[str, Any]) -> bool:
|
def _job_succeeded(job: dict[str, Any]) -> bool:
|
||||||
|
"""Return whether Kubernetes reports the sync Job as successfully complete."""
|
||||||
|
|
||||||
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
||||||
if int(status.get("succeeded") or 0) > 0:
|
if int(status.get("succeeded") or 0) > 0:
|
||||||
return True
|
return True
|
||||||
@ -83,6 +87,8 @@ def _job_succeeded(job: dict[str, Any]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _job_failed(job: dict[str, Any]) -> bool:
|
def _job_failed(job: dict[str, Any]) -> bool:
|
||||||
|
"""Return whether Kubernetes reports the sync Job as failed."""
|
||||||
|
|
||||||
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
||||||
if int(status.get("failed") or 0) > 0:
|
if int(status.get("failed") or 0) > 0:
|
||||||
return True
|
return True
|
||||||
@ -96,6 +102,12 @@ def _job_failed(job: dict[str, Any]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]:
|
def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]:
|
||||||
|
"""Start the Firefly sync Job for one user and optionally wait for completion.
|
||||||
|
|
||||||
|
WHY: account self-service should be able to repair one user without waiting
|
||||||
|
for the broader scheduled reconciliation loop.
|
||||||
|
"""
|
||||||
|
|
||||||
username = (username or "").strip()
|
username = (username or "").strip()
|
||||||
if not username:
|
if not username:
|
||||||
raise RuntimeError("missing username")
|
raise RuntimeError("missing username")
|
||||||
|
|||||||
@ -24,6 +24,8 @@ def _read_service_account() -> tuple[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def get_json(path: str) -> dict[str, Any]:
|
def get_json(path: str) -> dict[str, Any]:
|
||||||
|
"""Fetch a Kubernetes API object as JSON using the pod service account."""
|
||||||
|
|
||||||
token, ca_path = _read_service_account()
|
token, ca_path = _read_service_account()
|
||||||
url = f"{_K8S_BASE_URL}{path}"
|
url = f"{_K8S_BASE_URL}{path}"
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
@ -40,6 +42,8 @@ def get_json(path: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Post a JSON payload to Kubernetes using the pod service account."""
|
||||||
|
|
||||||
token, ca_path = _read_service_account()
|
token, ca_path = _read_service_account()
|
||||||
url = f"{_K8S_BASE_URL}{path}"
|
url = f"{_K8S_BASE_URL}{path}"
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
@ -53,4 +57,3 @@ def post_json(path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|||||||
if not isinstance(data, dict):
|
if not isinstance(data, dict):
|
||||||
raise RuntimeError("unexpected kubernetes response")
|
raise RuntimeError("unexpected kubernetes response")
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ from . import settings
|
|||||||
|
|
||||||
|
|
||||||
class KeycloakOIDC:
|
class KeycloakOIDC:
|
||||||
|
"""Verify user-facing Keycloak tokens for portal requests."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._jwk_client: PyJWKClient | None = None
|
self._jwk_client: PyJWKClient | None = None
|
||||||
|
|
||||||
@ -23,6 +25,12 @@ class KeycloakOIDC:
|
|||||||
return self._jwk_client
|
return self._jwk_client
|
||||||
|
|
||||||
def verify(self, token: str) -> dict[str, Any]:
|
def verify(self, token: str) -> dict[str, Any]:
|
||||||
|
"""Validate a bearer token and return decoded claims.
|
||||||
|
|
||||||
|
WHY: the portal trusts Keycloak groups and usernames only after issuer
|
||||||
|
and client ownership are checked locally.
|
||||||
|
"""
|
||||||
|
|
||||||
if not settings.KEYCLOAK_ENABLED:
|
if not settings.KEYCLOAK_ENABLED:
|
||||||
raise ValueError("keycloak not enabled")
|
raise ValueError("keycloak not enabled")
|
||||||
|
|
||||||
@ -50,6 +58,8 @@ class KeycloakOIDC:
|
|||||||
|
|
||||||
|
|
||||||
class KeycloakAdminClient:
|
class KeycloakAdminClient:
|
||||||
|
"""Perform service-account Keycloak admin operations for provisioning."""
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._token: str = ""
|
self._token: str = ""
|
||||||
self._expires_at: float = 0.0
|
self._expires_at: float = 0.0
|
||||||
@ -57,6 +67,12 @@ class KeycloakAdminClient:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _safe_update_payload(full: dict[str, Any]) -> dict[str, Any]:
|
def _safe_update_payload(full: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Extract mutable fields from a full Keycloak user document.
|
||||||
|
|
||||||
|
WHY: partial updates can accidentally clear profile or attribute data,
|
||||||
|
so callers merge desired changes into a safe copy first.
|
||||||
|
"""
|
||||||
|
|
||||||
payload: dict[str, Any] = {}
|
payload: dict[str, Any] = {}
|
||||||
username = full.get("username")
|
username = full.get("username")
|
||||||
if isinstance(username, str):
|
if isinstance(username, str):
|
||||||
@ -86,9 +102,13 @@ class KeycloakAdminClient:
|
|||||||
return payload
|
return payload
|
||||||
|
|
||||||
def ready(self) -> bool:
|
def ready(self) -> bool:
|
||||||
|
"""Return whether admin-client credentials are available."""
|
||||||
|
|
||||||
return bool(settings.KEYCLOAK_ADMIN_CLIENT_ID and settings.KEYCLOAK_ADMIN_CLIENT_SECRET)
|
return bool(settings.KEYCLOAK_ADMIN_CLIENT_ID and settings.KEYCLOAK_ADMIN_CLIENT_SECRET)
|
||||||
|
|
||||||
def _get_token(self) -> str:
|
def _get_token(self) -> str:
|
||||||
|
"""Return a cached service-account token, refreshing before expiry."""
|
||||||
|
|
||||||
if not self.ready():
|
if not self.ready():
|
||||||
raise RuntimeError("keycloak admin client not configured")
|
raise RuntimeError("keycloak admin client not configured")
|
||||||
|
|
||||||
@ -120,9 +140,13 @@ class KeycloakAdminClient:
|
|||||||
return {"Authorization": f"Bearer {self._get_token()}"}
|
return {"Authorization": f"Bearer {self._get_token()}"}
|
||||||
|
|
||||||
def headers(self) -> dict[str, str]:
|
def headers(self) -> dict[str, str]:
|
||||||
|
"""Return authorization headers for callers that need raw admin access."""
|
||||||
|
|
||||||
return self._headers()
|
return self._headers()
|
||||||
|
|
||||||
def find_user(self, username: str) -> dict[str, Any] | None:
|
def find_user(self, username: str) -> dict[str, Any] | None:
|
||||||
|
"""Look up one Keycloak user by exact username."""
|
||||||
|
|
||||||
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
|
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
|
||||||
# Keycloak 26.x in our environment intermittently 400s on filtered user queries unless `max` is set.
|
# Keycloak 26.x in our environment intermittently 400s on filtered user queries unless `max` is set.
|
||||||
# Use `max=1` and exact username match to keep admin calls reliable for portal provisioning.
|
# Use `max=1` and exact username match to keep admin calls reliable for portal provisioning.
|
||||||
@ -137,6 +161,8 @@ class KeycloakAdminClient:
|
|||||||
return user if isinstance(user, dict) else None
|
return user if isinstance(user, dict) else None
|
||||||
|
|
||||||
def find_user_by_email(self, email: str) -> dict[str, Any] | None:
|
def find_user_by_email(self, email: str) -> dict[str, Any] | None:
|
||||||
|
"""Look up one Keycloak user by exact email address."""
|
||||||
|
|
||||||
email = (email or "").strip()
|
email = (email or "").strip()
|
||||||
if not email:
|
if not email:
|
||||||
return None
|
return None
|
||||||
@ -161,6 +187,8 @@ class KeycloakAdminClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def get_user(self, user_id: str) -> dict[str, Any]:
|
def get_user(self, user_id: str) -> dict[str, Any]:
|
||||||
|
"""Fetch a full Keycloak user representation by ID."""
|
||||||
|
|
||||||
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
|
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
|
||||||
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||||
resp = client.get(url, headers=self._headers())
|
resp = client.get(url, headers=self._headers())
|
||||||
@ -171,12 +199,16 @@ class KeycloakAdminClient:
|
|||||||
return data
|
return data
|
||||||
|
|
||||||
def update_user(self, user_id: str, payload: dict[str, Any]) -> None:
|
def update_user(self, user_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
"""Replace a Keycloak user document with the supplied payload."""
|
||||||
|
|
||||||
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
|
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users/{quote(user_id, safe='')}"
|
||||||
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||||
resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
|
resp = client.put(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
|
||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
def update_user_safe(self, user_id: str, payload: dict[str, Any]) -> None:
|
def update_user_safe(self, user_id: str, payload: dict[str, Any]) -> None:
|
||||||
|
"""Merge selected user changes into the current Keycloak document."""
|
||||||
|
|
||||||
full = self.get_user(user_id)
|
full = self.get_user(user_id)
|
||||||
merged = self._safe_update_payload(full)
|
merged = self._safe_update_payload(full)
|
||||||
for key, value in payload.items():
|
for key, value in payload.items():
|
||||||
@ -192,6 +224,8 @@ class KeycloakAdminClient:
|
|||||||
self.update_user(user_id, merged)
|
self.update_user(user_id, merged)
|
||||||
|
|
||||||
def create_user(self, payload: dict[str, Any]) -> str:
|
def create_user(self, payload: dict[str, Any]) -> str:
|
||||||
|
"""Create a Keycloak user and return the generated user ID."""
|
||||||
|
|
||||||
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
|
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users"
|
||||||
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||||
resp = client.post(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
|
resp = client.post(url, headers={**self._headers(), "Content-Type": "application/json"}, json=payload)
|
||||||
@ -202,6 +236,8 @@ class KeycloakAdminClient:
|
|||||||
raise RuntimeError("failed to determine created user id")
|
raise RuntimeError("failed to determine created user id")
|
||||||
|
|
||||||
def reset_password(self, user_id: str, password: str, temporary: bool = True) -> None:
|
def reset_password(self, user_id: str, password: str, temporary: bool = True) -> None:
|
||||||
|
"""Set a Keycloak password credential for a user."""
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
||||||
f"/users/{quote(user_id, safe='')}/reset-password"
|
f"/users/{quote(user_id, safe='')}/reset-password"
|
||||||
@ -212,6 +248,8 @@ class KeycloakAdminClient:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
def set_user_attribute(self, username: str, key: str, value: str) -> None:
|
def set_user_attribute(self, username: str, key: str, value: str) -> None:
|
||||||
|
"""Set one single-value Keycloak user attribute by username."""
|
||||||
|
|
||||||
user = self.find_user(username)
|
user = self.find_user(username)
|
||||||
if not user:
|
if not user:
|
||||||
raise RuntimeError("user not found")
|
raise RuntimeError("user not found")
|
||||||
@ -230,6 +268,8 @@ class KeycloakAdminClient:
|
|||||||
self.update_user(user_id, payload)
|
self.update_user(user_id, payload)
|
||||||
|
|
||||||
def get_group_id(self, group_name: str) -> str | None:
|
def get_group_id(self, group_name: str) -> str | None:
|
||||||
|
"""Resolve and cache the Keycloak ID for a group name."""
|
||||||
|
|
||||||
cached = self._group_id_cache.get(group_name)
|
cached = self._group_id_cache.get(group_name)
|
||||||
if cached:
|
if cached:
|
||||||
return cached
|
return cached
|
||||||
@ -252,6 +292,8 @@ class KeycloakAdminClient:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def list_group_names(self) -> list[str]:
|
def list_group_names(self) -> list[str]:
|
||||||
|
"""Return all Keycloak group names visible to the admin client."""
|
||||||
|
|
||||||
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/groups"
|
url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/groups"
|
||||||
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client:
|
||||||
resp = client.get(url, headers=self._headers())
|
resp = client.get(url, headers=self._headers())
|
||||||
@ -263,6 +305,8 @@ class KeycloakAdminClient:
|
|||||||
names: set[str] = set()
|
names: set[str] = set()
|
||||||
|
|
||||||
def walk(groups: list[Any]) -> None:
|
def walk(groups: list[Any]) -> None:
|
||||||
|
"""Visit nested Keycloak group records and collect names."""
|
||||||
|
|
||||||
for group in groups:
|
for group in groups:
|
||||||
if not isinstance(group, dict):
|
if not isinstance(group, dict):
|
||||||
continue
|
continue
|
||||||
@ -277,6 +321,8 @@ class KeycloakAdminClient:
|
|||||||
return sorted(names)
|
return sorted(names)
|
||||||
|
|
||||||
def list_user_groups(self, user_id: str) -> list[str]:
|
def list_user_groups(self, user_id: str) -> list[str]:
|
||||||
|
"""Return group names assigned to one Keycloak user."""
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
||||||
f"/users/{quote(user_id, safe='')}/groups"
|
f"/users/{quote(user_id, safe='')}/groups"
|
||||||
@ -297,6 +343,8 @@ class KeycloakAdminClient:
|
|||||||
return names
|
return names
|
||||||
|
|
||||||
def add_user_to_group(self, user_id: str, group_id: str) -> None:
|
def add_user_to_group(self, user_id: str, group_id: str) -> None:
|
||||||
|
"""Attach one Keycloak user to one group by ID."""
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
||||||
f"/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}"
|
f"/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}"
|
||||||
@ -306,6 +354,8 @@ class KeycloakAdminClient:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
def execute_actions_email(self, user_id: str, actions: list[str], redirect_uri: str) -> None:
|
def execute_actions_email(self, user_id: str, actions: list[str], redirect_uri: str) -> None:
|
||||||
|
"""Ask Keycloak to email required-account-action links to a user."""
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
||||||
f"/users/{quote(user_id, safe='')}/execute-actions-email"
|
f"/users/{quote(user_id, safe='')}/execute-actions-email"
|
||||||
@ -321,6 +371,8 @@ class KeycloakAdminClient:
|
|||||||
resp.raise_for_status()
|
resp.raise_for_status()
|
||||||
|
|
||||||
def get_user_credentials(self, user_id: str) -> list[dict[str, Any]]:
|
def get_user_credentials(self, user_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Return credential metadata for one Keycloak user."""
|
||||||
|
|
||||||
url = (
|
url = (
|
||||||
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}"
|
||||||
f"/users/{quote(user_id, safe='')}/credentials"
|
f"/users/{quote(user_id, safe='')}/credentials"
|
||||||
@ -339,6 +391,8 @@ _ADMIN: KeycloakAdminClient | None = None
|
|||||||
|
|
||||||
|
|
||||||
def oidc_client() -> KeycloakOIDC:
|
def oidc_client() -> KeycloakOIDC:
|
||||||
|
"""Return the singleton OIDC verifier."""
|
||||||
|
|
||||||
global _OIDC
|
global _OIDC
|
||||||
if _OIDC is None:
|
if _OIDC is None:
|
||||||
_OIDC = KeycloakOIDC()
|
_OIDC = KeycloakOIDC()
|
||||||
@ -346,6 +400,8 @@ def oidc_client() -> KeycloakOIDC:
|
|||||||
|
|
||||||
|
|
||||||
def admin_client() -> KeycloakAdminClient:
|
def admin_client() -> KeycloakAdminClient:
|
||||||
|
"""Return the singleton Keycloak admin client."""
|
||||||
|
|
||||||
global _ADMIN
|
global _ADMIN
|
||||||
if _ADMIN is None:
|
if _ADMIN is None:
|
||||||
_ADMIN = KeycloakAdminClient()
|
_ADMIN = KeycloakAdminClient()
|
||||||
@ -364,6 +420,8 @@ def _normalize_groups(groups: Any) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _extract_bearer_token() -> str | None:
|
def _extract_bearer_token() -> str | None:
|
||||||
|
"""Extract a bearer token from the current Flask request."""
|
||||||
|
|
||||||
header = request.headers.get("Authorization", "")
|
header = request.headers.get("Authorization", "")
|
||||||
if not header:
|
if not header:
|
||||||
return None
|
return None
|
||||||
@ -377,8 +435,12 @@ def _extract_bearer_token() -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def require_auth(fn):
|
def require_auth(fn):
|
||||||
|
"""Decorate a Flask route so it requires a valid Keycloak bearer token."""
|
||||||
|
|
||||||
@wraps(fn)
|
@wraps(fn)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
|
"""Validate the request token and place normalized claims on Flask globals."""
|
||||||
|
|
||||||
token = _extract_bearer_token()
|
token = _extract_bearer_token()
|
||||||
if not token:
|
if not token:
|
||||||
return jsonify({"error": "missing bearer token"}), 401
|
return jsonify({"error": "missing bearer token"}), 401
|
||||||
@ -397,6 +459,8 @@ def require_auth(fn):
|
|||||||
|
|
||||||
|
|
||||||
def require_portal_admin() -> tuple[bool, Any]:
|
def require_portal_admin() -> tuple[bool, Any]:
|
||||||
|
"""Return whether the authenticated user can use portal admin actions."""
|
||||||
|
|
||||||
if not settings.KEYCLOAK_ENABLED:
|
if not settings.KEYCLOAK_ENABLED:
|
||||||
return False, (jsonify({"error": "keycloak not enabled"}), 503)
|
return False, (jsonify({"error": "keycloak not enabled"}), 503)
|
||||||
|
|
||||||
@ -411,6 +475,8 @@ def require_portal_admin() -> tuple[bool, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def require_account_access() -> tuple[bool, Any]:
|
def require_account_access() -> tuple[bool, Any]:
|
||||||
|
"""Return whether the authenticated user can use self-service account pages."""
|
||||||
|
|
||||||
if not settings.KEYCLOAK_ENABLED:
|
if not settings.KEYCLOAK_ENABLED:
|
||||||
return False, (jsonify({"error": "keycloak not enabled"}), 503)
|
return False, (jsonify({"error": "keycloak not enabled"}), 503)
|
||||||
if not settings.ACCOUNT_ALLOWED_GROUPS:
|
if not settings.ACCOUNT_ALLOWED_GROUPS:
|
||||||
|
|||||||
@ -7,10 +7,14 @@ from . import settings
|
|||||||
|
|
||||||
|
|
||||||
class MailerError(RuntimeError):
|
class MailerError(RuntimeError):
|
||||||
|
"""Signal a mail delivery problem without leaking provider internals."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
def send_text_email(*, to_addr: str, subject: str, body: str) -> None:
|
def send_text_email(*, to_addr: str, subject: str, body: str) -> None:
|
||||||
|
"""Send a plaintext email through the configured SMTP relay."""
|
||||||
|
|
||||||
if not to_addr:
|
if not to_addr:
|
||||||
raise MailerError("missing recipient")
|
raise MailerError("missing recipient")
|
||||||
if not settings.SMTP_HOST:
|
if not settings.SMTP_HOST:
|
||||||
@ -35,6 +39,8 @@ def send_text_email(*, to_addr: str, subject: str, body: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def access_request_verification_body(*, request_code: str, verify_url: str) -> str:
|
def access_request_verification_body(*, request_code: str, verify_url: str) -> str:
|
||||||
|
"""Render the access-request email verification message body."""
|
||||||
|
|
||||||
return "\n".join(
|
return "\n".join(
|
||||||
[
|
[
|
||||||
"Atlas — confirm your email",
|
"Atlas — confirm your email",
|
||||||
@ -50,4 +56,3 @@ def access_request_verification_body(*, request_code: str, verify_url: str) -> s
|
|||||||
"",
|
"",
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -4,6 +4,8 @@ from .db import run_migrations
|
|||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
|
"""Run database migrations when invoked as a module or script."""
|
||||||
|
|
||||||
run_migrations()
|
run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -16,6 +16,8 @@ def _safe_name_fragment(value: str, max_len: int = 24) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _job_from_cronjob(cronjob: dict[str, Any], username: str) -> dict[str, Any]:
|
def _job_from_cronjob(cronjob: dict[str, Any], username: str) -> dict[str, Any]:
|
||||||
|
"""Render a one-off Nextcloud mail sync Job from the CronJob template."""
|
||||||
|
|
||||||
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
|
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
|
||||||
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
|
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
|
||||||
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
|
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
|
||||||
@ -61,6 +63,8 @@ def _job_from_cronjob(cronjob: dict[str, Any], username: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _job_succeeded(job: dict[str, Any]) -> bool:
|
def _job_succeeded(job: dict[str, Any]) -> bool:
|
||||||
|
"""Return whether Kubernetes reports the sync Job as successfully complete."""
|
||||||
|
|
||||||
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
||||||
if int(status.get("succeeded") or 0) > 0:
|
if int(status.get("succeeded") or 0) > 0:
|
||||||
return True
|
return True
|
||||||
@ -74,6 +78,8 @@ def _job_succeeded(job: dict[str, Any]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _job_failed(job: dict[str, Any]) -> bool:
|
def _job_failed(job: dict[str, Any]) -> bool:
|
||||||
|
"""Return whether Kubernetes reports the sync Job as failed."""
|
||||||
|
|
||||||
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
||||||
if int(status.get("failed") or 0) > 0:
|
if int(status.get("failed") or 0) > 0:
|
||||||
return True
|
return True
|
||||||
@ -87,6 +93,12 @@ def _job_failed(job: dict[str, Any]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def trigger(username: str, wait: bool = True) -> dict[str, Any]:
|
def trigger(username: str, wait: bool = True) -> dict[str, Any]:
|
||||||
|
"""Start the Nextcloud mail sync Job for one user and optionally wait.
|
||||||
|
|
||||||
|
WHY: onboarding and account actions need a targeted sync repair path that
|
||||||
|
reuses the same template as scheduled cluster automation.
|
||||||
|
"""
|
||||||
|
|
||||||
username = (username or "").strip()
|
username = (username or "").strip()
|
||||||
if not username:
|
if not username:
|
||||||
raise RuntimeError("missing username")
|
raise RuntimeError("missing username")
|
||||||
@ -120,4 +132,3 @@ def trigger(username: str, wait: bool = True) -> dict[str, Any]:
|
|||||||
last_state = "running"
|
last_state = "running"
|
||||||
|
|
||||||
return {"job": job_name, "status": last_state}
|
return {"job": job_name, "status": last_state}
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,13 @@ def register_access_request_onboarding(app, deps) -> None:
|
|||||||
|
|
||||||
@app.route("/api/access/request/onboarding/attest", methods=["POST"])
|
@app.route("/api/access/request/onboarding/attest", methods=["POST"])
|
||||||
def request_access_onboarding_attest() -> Any:
|
def request_access_onboarding_attest() -> Any:
|
||||||
|
"""Record or clear a user-attested onboarding step.
|
||||||
|
|
||||||
|
WHY: onboarding mixes manual tasks with Keycloak-managed tasks, so this
|
||||||
|
route enforces prerequisites and only accepts attestations for UI-owned
|
||||||
|
steps.
|
||||||
|
"""
|
||||||
|
|
||||||
if not deps.configured():
|
if not deps.configured():
|
||||||
return jsonify({"error": "server not deps.configured"}), 503
|
return jsonify({"error": "server not deps.configured"}), 503
|
||||||
|
|
||||||
@ -161,6 +168,8 @@ def register_access_request_onboarding(app, deps) -> None:
|
|||||||
|
|
||||||
@app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"])
|
@app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"])
|
||||||
def request_access_onboarding_keycloak_password_rotate() -> Any:
|
def request_access_onboarding_keycloak_password_rotate() -> Any:
|
||||||
|
"""Request Keycloak password rotation for an onboarding user."""
|
||||||
|
|
||||||
if not deps.configured():
|
if not deps.configured():
|
||||||
return jsonify({"error": "server not deps.configured"}), 503
|
return jsonify({"error": "server not deps.configured"}), 503
|
||||||
|
|
||||||
|
|||||||
@ -101,6 +101,8 @@ def _send_verification_email(*, request_code: str, email: str, token: str) -> No
|
|||||||
|
|
||||||
|
|
||||||
class VerificationError(Exception):
|
class VerificationError(Exception):
|
||||||
|
"""Describe an email verification failure with an HTTP status."""
|
||||||
|
|
||||||
def __init__(self, status_code: int, message: str) -> None:
|
def __init__(self, status_code: int, message: str) -> None:
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.status_code = status_code
|
self.status_code = status_code
|
||||||
@ -108,6 +110,7 @@ class VerificationError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
def _verify_request(conn, code: str, token: str) -> str:
|
def _verify_request(conn, code: str, token: str) -> str:
|
||||||
|
"""Validate email proof and atomically advance a pending request."""
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at
|
SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at
|
||||||
@ -161,6 +164,7 @@ def _normalize_status(status: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]:
|
||||||
|
"""Return manually attested onboarding steps for one request."""
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT step FROM access_request_onboarding_steps WHERE request_code = %s",
|
"SELECT step FROM access_request_onboarding_steps WHERE request_code = %s",
|
||||||
(request_code,),
|
(request_code,),
|
||||||
@ -182,6 +186,7 @@ def _normalize_flag_list(raw: Any) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], str]:
|
def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], str]:
|
||||||
|
"""Return approval flags and contact email used by onboarding decisions."""
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT approval_flags, contact_email FROM access_requests WHERE request_code = %s",
|
"SELECT approval_flags, contact_email FROM access_requests WHERE request_code = %s",
|
||||||
(request_code,),
|
(request_code,),
|
||||||
@ -194,6 +199,7 @@ def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], s
|
|||||||
|
|
||||||
|
|
||||||
def _user_in_group(username: str, group_name: str) -> bool:
|
def _user_in_group(username: str, group_name: str) -> bool:
|
||||||
|
"""Return whether a Keycloak user belongs to a named group."""
|
||||||
if not username or not group_name:
|
if not username or not group_name:
|
||||||
return False
|
return False
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
@ -219,6 +225,7 @@ def _vaultwarden_grandfathered(conn, request_code: str, username: str) -> tuple[
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_recovery_email(username: str, fallback: str) -> str:
|
def _resolve_recovery_email(username: str, fallback: str) -> str:
|
||||||
|
"""Find the best recovery email for Vaultwarden onboarding."""
|
||||||
if username and admin_client().ready():
|
if username and admin_client().ready():
|
||||||
try:
|
try:
|
||||||
user = admin_client().find_user(username) or {}
|
user = admin_client().find_user(username) or {}
|
||||||
@ -234,6 +241,7 @@ def _resolve_recovery_email(username: str, fallback: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _password_rotation_requested(conn, request_code: str) -> bool:
|
def _password_rotation_requested(conn, request_code: str) -> bool:
|
||||||
|
"""Return whether Keycloak password rotation was requested for this request."""
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
SELECT 1
|
SELECT 1
|
||||||
@ -247,6 +255,7 @@ def _password_rotation_requested(conn, request_code: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _request_keycloak_password_rotation(conn, request_code: str, username: str) -> None:
|
def _request_keycloak_password_rotation(conn, request_code: str, username: str) -> None:
|
||||||
|
"""Require Keycloak password rotation and persist the request marker."""
|
||||||
if not username:
|
if not username:
|
||||||
raise ValueError("username missing")
|
raise ValueError("username missing")
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
@ -277,6 +286,7 @@ def _request_keycloak_password_rotation(conn, request_code: str, username: str)
|
|||||||
|
|
||||||
|
|
||||||
def _extract_attr(attrs: Any, key: str) -> str:
|
def _extract_attr(attrs: Any, key: str) -> str:
|
||||||
|
"""Return the first string value for a Keycloak attribute."""
|
||||||
if not isinstance(attrs, dict):
|
if not isinstance(attrs, dict):
|
||||||
return ""
|
return ""
|
||||||
raw = attrs.get(key)
|
raw = attrs.get(key)
|
||||||
@ -291,6 +301,7 @@ def _extract_attr(attrs: Any, key: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _vaultwarden_status_for_user(username: str) -> str:
|
def _vaultwarden_status_for_user(username: str) -> str:
|
||||||
|
"""Read the Vaultwarden lifecycle status mirrored on a Keycloak user."""
|
||||||
if not username:
|
if not username:
|
||||||
return ""
|
return ""
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
@ -308,6 +319,7 @@ def _vaultwarden_status_for_user(username: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _auto_completed_service_steps(attrs: Any) -> set[str]:
|
def _auto_completed_service_steps(attrs: Any) -> set[str]:
|
||||||
|
"""Infer onboarding steps completed by backend service automation."""
|
||||||
completed: set[str] = set()
|
completed: set[str] = set()
|
||||||
if not isinstance(attrs, dict):
|
if not isinstance(attrs, dict):
|
||||||
return completed
|
return completed
|
||||||
@ -333,6 +345,7 @@ def _auto_completed_service_steps(attrs: Any) -> set[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]:
|
def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]:
|
||||||
|
"""Infer onboarding steps from Keycloak profile state."""
|
||||||
if not username:
|
if not username:
|
||||||
return set()
|
return set()
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
@ -389,6 +402,7 @@ def _completed_onboarding_steps(conn, request_code: str, username: str) -> set[s
|
|||||||
|
|
||||||
|
|
||||||
def _automation_ready(conn, request_code: str, username: str) -> bool:
|
def _automation_ready(conn, request_code: str, username: str) -> bool:
|
||||||
|
"""Return whether account provisioning has finished enough for onboarding."""
|
||||||
if not username:
|
if not username:
|
||||||
return False
|
return False
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
@ -423,6 +437,7 @@ def _automation_ready(conn, request_code: str, username: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
||||||
|
"""Advance an access request through automatic status transitions."""
|
||||||
status = _normalize_status(status)
|
status = _normalize_status(status)
|
||||||
|
|
||||||
if status == "accounts_building" and _automation_ready(conn, request_code, username):
|
if status == "accounts_building" and _automation_ready(conn, request_code, username):
|
||||||
@ -450,6 +465,7 @@ def _advance_status(conn, request_code: str, username: str, status: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]:
|
def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]:
|
||||||
|
"""Build the onboarding progress payload returned to the frontend."""
|
||||||
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
|
completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username))
|
||||||
password_rotation_requested = _password_rotation_requested(conn, request_code)
|
password_rotation_requested = _password_rotation_requested(conn, request_code)
|
||||||
grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username)
|
grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username)
|
||||||
|
|||||||
@ -11,6 +11,12 @@ def register_access_request_status(app, deps) -> None:
|
|||||||
|
|
||||||
@app.route("/api/access/request/status", methods=["POST"])
|
@app.route("/api/access/request/status", methods=["POST"])
|
||||||
def request_access_status() -> Any:
|
def request_access_status() -> Any:
|
||||||
|
"""Return current provisioning and onboarding status for a request.
|
||||||
|
|
||||||
|
WHY: this endpoint is polled by the public flow, so it also advances
|
||||||
|
safe automatic transitions before rendering the latest state.
|
||||||
|
"""
|
||||||
|
|
||||||
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
||||||
return jsonify({"error": "request access disabled"}), 503
|
return jsonify({"error": "request access disabled"}), 503
|
||||||
if not deps.configured():
|
if not deps.configured():
|
||||||
@ -142,6 +148,8 @@ def register_access_request_status(app, deps) -> None:
|
|||||||
|
|
||||||
@app.route("/api/access/request/retry", methods=["POST"])
|
@app.route("/api/access/request/retry", methods=["POST"])
|
||||||
def request_access_retry() -> Any:
|
def request_access_retry() -> Any:
|
||||||
|
"""Retry failed provisioning tasks for an access request."""
|
||||||
|
|
||||||
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
||||||
return jsonify({"error": "request access disabled"}), 503
|
return jsonify({"error": "request access disabled"}), 503
|
||||||
if not deps.configured():
|
if not deps.configured():
|
||||||
|
|||||||
@ -13,6 +13,8 @@ def register_access_request_submission(app, deps) -> None:
|
|||||||
|
|
||||||
@app.route("/api/access/request/availability", methods=["GET"])
|
@app.route("/api/access/request/availability", methods=["GET"])
|
||||||
def request_access_availability() -> Any:
|
def request_access_availability() -> Any:
|
||||||
|
"""Report whether a requested username can start access signup."""
|
||||||
|
|
||||||
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
||||||
return jsonify({"error": "request access disabled"}), 503
|
return jsonify({"error": "request access disabled"}), 503
|
||||||
if not deps.configured():
|
if not deps.configured():
|
||||||
@ -55,6 +57,12 @@ def register_access_request_submission(app, deps) -> None:
|
|||||||
|
|
||||||
@app.route("/api/access/request", methods=["POST"])
|
@app.route("/api/access/request", methods=["POST"])
|
||||||
def request_access() -> Any:
|
def request_access() -> Any:
|
||||||
|
"""Create or refresh an email-verified access request.
|
||||||
|
|
||||||
|
WHY: submissions are anonymous, so this route validates names, rate
|
||||||
|
limits by client/request, and emails a proof token before queuing work.
|
||||||
|
"""
|
||||||
|
|
||||||
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
||||||
return jsonify({"error": "request access disabled"}), 503
|
return jsonify({"error": "request access disabled"}), 503
|
||||||
if not deps.configured():
|
if not deps.configured():
|
||||||
@ -202,6 +210,8 @@ def register_access_request_submission(app, deps) -> None:
|
|||||||
|
|
||||||
@app.route("/api/access/request/verify", methods=["POST"])
|
@app.route("/api/access/request/verify", methods=["POST"])
|
||||||
def request_access_verify() -> Any:
|
def request_access_verify() -> Any:
|
||||||
|
"""Verify a submitted access request using a request code and token."""
|
||||||
|
|
||||||
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
||||||
return jsonify({"error": "request access disabled"}), 503
|
return jsonify({"error": "request access disabled"}), 503
|
||||||
if not deps.configured():
|
if not deps.configured():
|
||||||
@ -246,6 +256,8 @@ def register_access_request_submission(app, deps) -> None:
|
|||||||
|
|
||||||
@app.route("/api/access/request/verify-link", methods=["GET"])
|
@app.route("/api/access/request/verify-link", methods=["GET"])
|
||||||
def request_access_verify_link() -> Any:
|
def request_access_verify_link() -> Any:
|
||||||
|
"""Verify an emailed access-request link and redirect to the UI."""
|
||||||
|
|
||||||
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
||||||
return jsonify({"error": "request access disabled"}), 503
|
return jsonify({"error": "request access disabled"}), 503
|
||||||
if not deps.configured():
|
if not deps.configured():
|
||||||
@ -267,6 +279,8 @@ def register_access_request_submission(app, deps) -> None:
|
|||||||
|
|
||||||
@app.route("/api/access/request/resend", methods=["POST"])
|
@app.route("/api/access/request/resend", methods=["POST"])
|
||||||
def request_access_resend() -> Any:
|
def request_access_resend() -> Any:
|
||||||
|
"""Send a fresh verification token for a pending access request."""
|
||||||
|
|
||||||
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
if not deps.settings.ACCESS_REQUEST_ENABLED:
|
||||||
return jsonify({"error": "request access disabled"}), 503
|
return jsonify({"error": "request access disabled"}), 503
|
||||||
if not deps.configured():
|
if not deps.configured():
|
||||||
|
|||||||
@ -34,6 +34,8 @@ def register_account_actions(app) -> None:
|
|||||||
@app.route("/api/account/mailu/rotate", methods=["POST"])
|
@app.route("/api/account/mailu/rotate", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def account_mailu_rotate() -> Any:
|
def account_mailu_rotate() -> Any:
|
||||||
|
"""Rotate the user's Mailu app password and trigger dependent syncs."""
|
||||||
|
|
||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
@ -87,6 +89,8 @@ def register_account_actions(app) -> None:
|
|||||||
@app.route("/api/account/wger/reset", methods=["POST"])
|
@app.route("/api/account/wger/reset", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def account_wger_reset() -> Any:
|
def account_wger_reset() -> Any:
|
||||||
|
"""Reset the user's Wger password through the sync Job path."""
|
||||||
|
|
||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
@ -140,6 +144,8 @@ def register_account_actions(app) -> None:
|
|||||||
@app.route("/api/account/wger/rotation/check", methods=["POST"])
|
@app.route("/api/account/wger/rotation/check", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def account_wger_rotation_check() -> Any:
|
def account_wger_rotation_check() -> Any:
|
||||||
|
"""Proxy or reject Wger rotation status checks for this account."""
|
||||||
|
|
||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
@ -150,6 +156,8 @@ def register_account_actions(app) -> None:
|
|||||||
@app.route("/api/account/firefly/reset", methods=["POST"])
|
@app.route("/api/account/firefly/reset", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def account_firefly_reset() -> Any:
|
def account_firefly_reset() -> Any:
|
||||||
|
"""Reset the user's Firefly password through the sync Job path."""
|
||||||
|
|
||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
@ -203,6 +211,8 @@ def register_account_actions(app) -> None:
|
|||||||
@app.route("/api/account/firefly/rotation/check", methods=["POST"])
|
@app.route("/api/account/firefly/rotation/check", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def account_firefly_rotation_check() -> Any:
|
def account_firefly_rotation_check() -> Any:
|
||||||
|
"""Proxy or reject Firefly rotation status checks for this account."""
|
||||||
|
|
||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
@ -213,6 +223,8 @@ def register_account_actions(app) -> None:
|
|||||||
@app.route("/api/account/nextcloud/mail/sync", methods=["POST"])
|
@app.route("/api/account/nextcloud/mail/sync", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def account_nextcloud_mail_sync() -> Any:
|
def account_nextcloud_mail_sync() -> Any:
|
||||||
|
"""Trigger a targeted Nextcloud mail sync for the signed-in user."""
|
||||||
|
|
||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
|
|||||||
@ -34,6 +34,12 @@ def register_account_overview(app) -> None:
|
|||||||
@app.route("/api/account/overview", methods=["GET"])
|
@app.route("/api/account/overview", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def account_overview() -> Any:
|
def account_overview() -> Any:
|
||||||
|
"""Build the signed-in user's self-service account dashboard state.
|
||||||
|
|
||||||
|
WHY: the UI needs one coherent status payload assembled from Keycloak,
|
||||||
|
service sync markers, and legacy fallback checks.
|
||||||
|
"""
|
||||||
|
|
||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
|
|||||||
@ -12,9 +12,13 @@ from ..provisioning import provision_access_request
|
|||||||
|
|
||||||
|
|
||||||
def register(app) -> None:
|
def register(app) -> None:
|
||||||
|
"""Register administrator routes for access-request decisions."""
|
||||||
|
|
||||||
@app.route("/api/admin/access/requests", methods=["GET"])
|
@app.route("/api/admin/access/requests", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def admin_list_requests() -> Any:
|
def admin_list_requests() -> Any:
|
||||||
|
"""List pending access requests for portal administrators."""
|
||||||
|
|
||||||
ok, resp = require_portal_admin()
|
ok, resp = require_portal_admin()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
@ -56,6 +60,8 @@ def register(app) -> None:
|
|||||||
@app.route("/api/admin/access/flags", methods=["GET"])
|
@app.route("/api/admin/access/flags", methods=["GET"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def admin_list_flags() -> Any:
|
def admin_list_flags() -> Any:
|
||||||
|
"""List Keycloak groups that can be applied as approval flags."""
|
||||||
|
|
||||||
ok, resp = require_portal_admin()
|
ok, resp = require_portal_admin()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
@ -74,6 +80,12 @@ def register(app) -> None:
|
|||||||
@app.route("/api/admin/access/requests/<username>/approve", methods=["POST"])
|
@app.route("/api/admin/access/requests/<username>/approve", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def admin_approve_request(username: str) -> Any:
|
def admin_approve_request(username: str) -> Any:
|
||||||
|
"""Approve one verified access request and start provisioning.
|
||||||
|
|
||||||
|
WHY: approval should atomically record the admin decision before
|
||||||
|
best-effort provisioning so status polling can surface any later issue.
|
||||||
|
"""
|
||||||
|
|
||||||
ok, resp = require_portal_admin()
|
ok, resp = require_portal_admin()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
@ -125,6 +137,8 @@ def register(app) -> None:
|
|||||||
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
|
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
|
||||||
@require_auth
|
@require_auth
|
||||||
def admin_deny_request(username: str) -> Any:
|
def admin_deny_request(username: str) -> Any:
|
||||||
|
"""Deny one pending access request with optional admin context."""
|
||||||
|
|
||||||
ok, resp = require_portal_admin()
|
ok, resp = require_portal_admin()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
|
|||||||
@ -13,9 +13,13 @@ from .. import settings
|
|||||||
|
|
||||||
|
|
||||||
def register(app) -> None:
|
def register(app) -> None:
|
||||||
|
"""Register the Atlas AI chat and model-info endpoints."""
|
||||||
|
|
||||||
@app.route("/api/chat", methods=["POST"])
|
@app.route("/api/chat", methods=["POST"])
|
||||||
@app.route("/api/ai/chat", methods=["POST"])
|
@app.route("/api/ai/chat", methods=["POST"])
|
||||||
def ai_chat() -> Any:
|
def ai_chat() -> Any:
|
||||||
|
"""Return an Atlasbot answer or a budget-aware fallback message."""
|
||||||
|
|
||||||
payload = request.get_json(silent=True) or {}
|
payload = request.get_json(silent=True) or {}
|
||||||
user_message = (payload.get("message") or "").strip()
|
user_message = (payload.get("message") or "").strip()
|
||||||
profile = (payload.get("profile") or payload.get("mode") or "atlas-quick").strip().lower()
|
profile = (payload.get("profile") or payload.get("mode") or "atlas-quick").strip().lower()
|
||||||
@ -61,6 +65,8 @@ def register(app) -> None:
|
|||||||
@app.route("/api/chat/info", methods=["GET"])
|
@app.route("/api/chat/info", methods=["GET"])
|
||||||
@app.route("/api/ai/info", methods=["GET"])
|
@app.route("/api/ai/info", methods=["GET"])
|
||||||
def ai_info() -> Any:
|
def ai_info() -> Any:
|
||||||
|
"""Return model and placement metadata for the requested AI profile."""
|
||||||
|
|
||||||
profile = (request.args.get("profile") or "atlas-quick").strip().lower()
|
profile = (request.args.get("profile") or "atlas-quick").strip().lower()
|
||||||
meta = _discover_ai_meta(profile)
|
meta = _discover_ai_meta(profile)
|
||||||
return jsonify(meta)
|
return jsonify(meta)
|
||||||
@ -69,6 +75,8 @@ def register(app) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _atlasbot_answer(message: str, mode: str, conversation_id: str) -> str:
|
def _atlasbot_answer(message: str, mode: str, conversation_id: str) -> str:
|
||||||
|
"""Ask Atlasbot for one answer and return an empty string on soft failure."""
|
||||||
|
|
||||||
endpoint = settings.AI_ATLASBOT_ENDPOINT
|
endpoint = settings.AI_ATLASBOT_ENDPOINT
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
return ""
|
return ""
|
||||||
@ -99,6 +107,12 @@ def _atlasbot_timeout_sec(mode: str) -> float:
|
|||||||
|
|
||||||
|
|
||||||
def _discover_ai_meta(profile: str) -> dict[str, str]:
|
def _discover_ai_meta(profile: str) -> dict[str, str]:
|
||||||
|
"""Discover AI model metadata from settings and the running Kubernetes pod.
|
||||||
|
|
||||||
|
WHY: the frontend needs a human-readable model/GPU hint even when the model
|
||||||
|
image or GPU placement changes outside the portal code.
|
||||||
|
"""
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
"node": settings.AI_NODE_NAME,
|
"node": settings.AI_NODE_NAME,
|
||||||
"gpu": settings.AI_GPU_DESC,
|
"gpu": settings.AI_GPU_DESC,
|
||||||
@ -168,10 +182,14 @@ def _discover_ai_meta(profile: str) -> dict[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _start_keep_warm() -> None:
|
def _start_keep_warm() -> None:
|
||||||
|
"""Start the optional background keep-warm loop for the chat backend."""
|
||||||
|
|
||||||
if not settings.AI_WARM_ENABLED or settings.AI_WARM_INTERVAL_SEC <= 0:
|
if not settings.AI_WARM_ENABLED or settings.AI_WARM_INTERVAL_SEC <= 0:
|
||||||
return
|
return
|
||||||
|
|
||||||
def loop() -> None:
|
def loop() -> None:
|
||||||
|
"""Periodically send a tiny chat request so the backend stays warm."""
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
time.sleep(settings.AI_WARM_INTERVAL_SEC)
|
time.sleep(settings.AI_WARM_INTERVAL_SEC)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@ -15,6 +15,8 @@ _LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
|
|||||||
|
|
||||||
|
|
||||||
def _vm_query(expr: str) -> float | None:
|
def _vm_query(expr: str) -> float | None:
|
||||||
|
"""Run one instant VictoriaMetrics query and return the largest value."""
|
||||||
|
|
||||||
url = f"{settings.VM_BASE_URL}/api/v1/query?{urlencode({'query': expr})}"
|
url = f"{settings.VM_BASE_URL}/api/v1/query?{urlencode({'query': expr})}"
|
||||||
with urlopen(url, timeout=settings.VM_QUERY_TIMEOUT_SEC) as resp:
|
with urlopen(url, timeout=settings.VM_QUERY_TIMEOUT_SEC) as resp:
|
||||||
payload = json.loads(resp.read().decode("utf-8"))
|
payload = json.loads(resp.read().decode("utf-8"))
|
||||||
@ -40,6 +42,8 @@ def _vm_query(expr: str) -> float | None:
|
|||||||
|
|
||||||
|
|
||||||
def _http_ok(url: str, expect_substring: str | None = None) -> bool:
|
def _http_ok(url: str, expect_substring: str | None = None) -> bool:
|
||||||
|
"""Return whether a URL responds successfully and optionally contains text."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with urlopen(url, timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as resp:
|
with urlopen(url, timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as resp:
|
||||||
if getattr(resp, "status", 200) != 200:
|
if getattr(resp, "status", 200) != 200:
|
||||||
@ -53,8 +57,12 @@ def _http_ok(url: str, expect_substring: str | None = None) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def register(app) -> None:
|
def register(app) -> None:
|
||||||
|
"""Register the lightweight lab connectivity status endpoint."""
|
||||||
|
|
||||||
@app.route("/api/lab/status")
|
@app.route("/api/lab/status")
|
||||||
def lab_status() -> Any:
|
def lab_status() -> Any:
|
||||||
|
"""Return cached Atlas/Oceanus health hints for the home page."""
|
||||||
|
|
||||||
now = time.time()
|
now = time.time()
|
||||||
cached = _LAB_STATUS_CACHE.get("value")
|
cached = _LAB_STATUS_CACHE.get("value")
|
||||||
if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC):
|
if cached and (now - float(_LAB_STATUS_CACHE.get("ts", 0.0)) < settings.LAB_STATUS_CACHE_SEC):
|
||||||
|
|||||||
@ -28,6 +28,8 @@ def _read_service_account() -> tuple[str, str]:
|
|||||||
|
|
||||||
|
|
||||||
def _k8s_get_json(path: str) -> dict[str, Any]:
|
def _k8s_get_json(path: str) -> dict[str, Any]:
|
||||||
|
"""Fetch a Kubernetes object as JSON through the pod service account."""
|
||||||
|
|
||||||
token, ca_path = _read_service_account()
|
token, ca_path = _read_service_account()
|
||||||
url = f"{_K8S_BASE_URL}{path}"
|
url = f"{_K8S_BASE_URL}{path}"
|
||||||
with httpx.Client(
|
with httpx.Client(
|
||||||
@ -44,12 +46,16 @@ def _k8s_get_json(path: str) -> dict[str, Any]:
|
|||||||
|
|
||||||
|
|
||||||
def _k8s_find_pod_ip(namespace: str, label_selector: str) -> str:
|
def _k8s_find_pod_ip(namespace: str, label_selector: str) -> str:
|
||||||
|
"""Find a usable Vaultwarden pod IP for direct admin fallback."""
|
||||||
|
|
||||||
data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/pods?labelSelector={label_selector}")
|
data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/pods?labelSelector={label_selector}")
|
||||||
items = data.get("items") or []
|
items = data.get("items") or []
|
||||||
if not isinstance(items, list) or not items:
|
if not isinstance(items, list) or not items:
|
||||||
raise RuntimeError("no vaultwarden pods found")
|
raise RuntimeError("no vaultwarden pods found")
|
||||||
|
|
||||||
def _pod_ready(pod: dict[str, Any]) -> bool:
|
def _pod_ready(pod: dict[str, Any]) -> bool:
|
||||||
|
"""Return whether a listed pod is running and ready enough to contact."""
|
||||||
|
|
||||||
status = pod.get("status") if isinstance(pod.get("status"), dict) else {}
|
status = pod.get("status") if isinstance(pod.get("status"), dict) else {}
|
||||||
if status.get("phase") != "Running":
|
if status.get("phase") != "Running":
|
||||||
return False
|
return False
|
||||||
@ -74,6 +80,8 @@ def _k8s_find_pod_ip(namespace: str, label_selector: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _k8s_get_secret_value(namespace: str, name: str, key: str) -> str:
|
def _k8s_get_secret_value(namespace: str, name: str, key: str) -> str:
|
||||||
|
"""Read and decode one Kubernetes Secret value."""
|
||||||
|
|
||||||
data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/secrets/{name}")
|
data = _k8s_get_json(f"/api/v1/namespaces/{namespace}/secrets/{name}")
|
||||||
blob = data.get("data") if isinstance(data.get("data"), dict) else {}
|
blob = data.get("data") if isinstance(data.get("data"), dict) else {}
|
||||||
raw = blob.get(key)
|
raw = blob.get(key)
|
||||||
@ -90,6 +98,8 @@ def _k8s_get_secret_value(namespace: str, name: str, key: str) -> str:
|
|||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class VaultwardenInvite:
|
class VaultwardenInvite:
|
||||||
|
"""Describe the result of attempting to create a Vaultwarden invite."""
|
||||||
|
|
||||||
ok: bool
|
ok: bool
|
||||||
status: str
|
status: str
|
||||||
detail: str = ""
|
detail: str = ""
|
||||||
@ -103,6 +113,12 @@ _ADMIN_RATE_LIMITED_UNTIL: float = 0.0
|
|||||||
|
|
||||||
|
|
||||||
def _admin_session(base_url: str) -> httpx.Client:
|
def _admin_session(base_url: str) -> httpx.Client:
|
||||||
|
"""Return a cached authenticated Vaultwarden admin session.
|
||||||
|
|
||||||
|
WHY: Vaultwarden rate-limits admin login attempts, so invite creation must
|
||||||
|
reuse a short-lived session instead of logging in for every user action.
|
||||||
|
"""
|
||||||
|
|
||||||
global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL, _ADMIN_RATE_LIMITED_UNTIL
|
global _ADMIN_SESSION, _ADMIN_SESSION_EXPIRES_AT, _ADMIN_SESSION_BASE_URL, _ADMIN_RATE_LIMITED_UNTIL
|
||||||
now = time.time()
|
now = time.time()
|
||||||
with _ADMIN_LOCK:
|
with _ADMIN_LOCK:
|
||||||
@ -146,6 +162,8 @@ def _admin_session(base_url: str) -> httpx.Client:
|
|||||||
|
|
||||||
|
|
||||||
def invite_user(email: str) -> VaultwardenInvite:
|
def invite_user(email: str) -> VaultwardenInvite:
|
||||||
|
"""Invite one email address to Vaultwarden through the admin UI."""
|
||||||
|
|
||||||
global _ADMIN_RATE_LIMITED_UNTIL
|
global _ADMIN_RATE_LIMITED_UNTIL
|
||||||
email = (email or "").strip()
|
email = (email or "").strip()
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
|
|||||||
@ -21,6 +21,8 @@ def _job_from_cronjob(
|
|||||||
email: str,
|
email: str,
|
||||||
password: str,
|
password: str,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Render a one-off Wger user sync Job from the managed CronJob template."""
|
||||||
|
|
||||||
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
|
spec = cronjob.get("spec") if isinstance(cronjob.get("spec"), dict) else {}
|
||||||
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
|
jt = spec.get("jobTemplate") if isinstance(spec.get("jobTemplate"), dict) else {}
|
||||||
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
|
job_spec = jt.get("spec") if isinstance(jt.get("spec"), dict) else {}
|
||||||
@ -71,6 +73,8 @@ def _job_from_cronjob(
|
|||||||
|
|
||||||
|
|
||||||
def _job_succeeded(job: dict[str, Any]) -> bool:
|
def _job_succeeded(job: dict[str, Any]) -> bool:
|
||||||
|
"""Return whether Kubernetes reports the sync Job as successfully complete."""
|
||||||
|
|
||||||
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
||||||
if int(status.get("succeeded") or 0) > 0:
|
if int(status.get("succeeded") or 0) > 0:
|
||||||
return True
|
return True
|
||||||
@ -84,6 +88,8 @@ def _job_succeeded(job: dict[str, Any]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def _job_failed(job: dict[str, Any]) -> bool:
|
def _job_failed(job: dict[str, Any]) -> bool:
|
||||||
|
"""Return whether Kubernetes reports the sync Job as failed."""
|
||||||
|
|
||||||
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
status = job.get("status") if isinstance(job.get("status"), dict) else {}
|
||||||
if int(status.get("failed") or 0) > 0:
|
if int(status.get("failed") or 0) > 0:
|
||||||
return True
|
return True
|
||||||
@ -97,6 +103,12 @@ def _job_failed(job: dict[str, Any]) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]:
|
def trigger(username: str, email: str, password: str, wait: bool = True) -> dict[str, Any]:
|
||||||
|
"""Start the Wger sync Job for one user and optionally wait for completion.
|
||||||
|
|
||||||
|
WHY: account actions need an immediate per-user repair path without
|
||||||
|
mutating the reusable CronJob template that Flux owns.
|
||||||
|
"""
|
||||||
|
|
||||||
username = (username or "").strip()
|
username = (username or "").strip()
|
||||||
if not username:
|
if not username:
|
||||||
raise RuntimeError("missing username")
|
raise RuntimeError("missing username")
|
||||||
|
|||||||
@ -256,6 +256,7 @@ export function useAccountDashboard() {
|
|||||||
return Array.isArray(selected) && selected.includes(flag);
|
return Array.isArray(selected) && selected.includes(flag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WHY: admins need readable applicant names even when optional fields are missing; @returns display name.
|
||||||
function formatName(req) {
|
function formatName(req) {
|
||||||
if (!req) return "unknown";
|
if (!req) return "unknown";
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@ -380,6 +381,7 @@ export function useAccountDashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WHY: Safari/private-mode clipboard support varies; @returns whether fallback copy completed.
|
||||||
function fallbackCopy(text) {
|
function fallbackCopy(text) {
|
||||||
const textarea = document.createElement("textarea");
|
const textarea = document.createElement("textarea");
|
||||||
textarea.value = text;
|
textarea.value = text;
|
||||||
|
|||||||
@ -67,6 +67,11 @@ const renderDiagram = async () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel pending Mermaid rendering work before scheduling a replacement.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
function cancelScheduledRender() {
|
function cancelScheduledRender() {
|
||||||
if (!scheduledHandle) return;
|
if (!scheduledHandle) return;
|
||||||
if (scheduledKind === "idle" && window.cancelIdleCallback) {
|
if (scheduledKind === "idle" && window.cancelIdleCallback) {
|
||||||
@ -78,6 +83,14 @@ function cancelScheduledRender() {
|
|||||||
scheduledKind = "";
|
scheduledKind = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule Mermaid rendering during idle time when the browser supports it.
|
||||||
|
*
|
||||||
|
* WHY: diagrams are decorative and can be expensive, so rendering should not
|
||||||
|
* compete with first paint or input responsiveness.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
function scheduleRenderDiagram() {
|
function scheduleRenderDiagram() {
|
||||||
cancelScheduledRender();
|
cancelScheduledRender();
|
||||||
if (!props.diagram) return;
|
if (!props.diagram) return;
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
/** Display labels and pill classes for onboarding status values. */
|
/**
|
||||||
|
* Convert an onboarding status code into display copy.
|
||||||
|
*
|
||||||
|
* @param {string} value - Raw status returned by the backend.
|
||||||
|
* @returns {string} Human-readable status label.
|
||||||
|
*/
|
||||||
export function statusLabel(value) {
|
export function statusLabel(value) {
|
||||||
const key = (value || "").trim();
|
const key = (value || "").trim();
|
||||||
if (key === "pending_email_verification") return "confirm email";
|
if (key === "pending_email_verification") return "confirm email";
|
||||||
@ -8,8 +13,14 @@ export function statusLabel(value) {
|
|||||||
if (key === "ready") return "ready";
|
if (key === "ready") return "ready";
|
||||||
if (key === "denied") return "rejected";
|
if (key === "denied") return "rejected";
|
||||||
return key || "unknown";
|
return key || "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an onboarding status code into the matching pill class.
|
||||||
|
*
|
||||||
|
* @param {string} value - Raw status returned by the backend.
|
||||||
|
* @returns {string} CSS class for status emphasis.
|
||||||
|
*/
|
||||||
export function statusPillClass(value) {
|
export function statusPillClass(value) {
|
||||||
const key = (value || "").trim();
|
const key = (value || "").trim();
|
||||||
if (key === "pending_email_verification") return "pill-warn";
|
if (key === "pending_email_verification") return "pill-warn";
|
||||||
@ -19,11 +30,17 @@ export function statusPillClass(value) {
|
|||||||
if (key === "ready") return "pill-info";
|
if (key === "ready") return "pill-info";
|
||||||
if (key === "denied") return "pill-bad";
|
if (key === "denied") return "pill-bad";
|
||||||
return "pill-warn";
|
return "pill-warn";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a provisioning task status into a pill class.
|
||||||
|
*
|
||||||
|
* @param {string} value - Raw task status returned by the backend.
|
||||||
|
* @returns {string} CSS class for task emphasis.
|
||||||
|
*/
|
||||||
export function taskPillClass(value) {
|
export function taskPillClass(value) {
|
||||||
const key = (value || "").trim();
|
const key = (value || "").trim();
|
||||||
if (key === "ok") return "pill-ok";
|
if (key === "ok") return "pill-ok";
|
||||||
if (key === "error") return "pill-bad";
|
if (key === "error") return "pill-bad";
|
||||||
return "pill-warn";
|
return "pill-warn";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,11 +7,9 @@ import { SECTION_DEFS, STEP_PREREQS, VAULTWARDEN_TEMP_STEP } from "./onboardingS
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the Onboarding page state machine.
|
* Build the Onboarding page state machine.
|
||||||
*
|
|
||||||
* WHY: onboarding coordinates request status, guide media, password reveal,
|
* WHY: onboarding coordinates request status, guide media, password reveal,
|
||||||
* and service attestation flow; isolating that state keeps the view focused
|
* and service attestation flow; isolating that state keeps the view focused
|
||||||
* on layout and makes the workflow independently testable.
|
* on layout and makes the workflow independently testable.
|
||||||
*
|
|
||||||
* @param {import("vue-router").RouteLocationNormalizedLoaded} route - active route with optional request code query params.
|
* @param {import("vue-router").RouteLocationNormalizedLoaded} route - active route with optional request code query params.
|
||||||
* @returns {object} reactive onboarding state and event handlers.
|
* @returns {object} reactive onboarding state and event handlers.
|
||||||
*/
|
*/
|
||||||
@ -118,6 +116,7 @@ export function useOnboardingFlow(route) {
|
|||||||
return prereqs.some((req) => !isStepDone(req));
|
return prereqs.some((req) => !isStepDone(req));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WHY: service login rules vary by step; @returns optional helper copy.
|
||||||
function stepNote(step) {
|
function stepNote(step) {
|
||||||
if (step.id === "vaultwarden_master_password") {
|
if (step.id === "vaultwarden_master_password") {
|
||||||
return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmailLower.value} to sign in.`;
|
return `Vaultwarden uses an email login. Use ${vaultwardenLoginEmailLower.value} to sign in.`;
|
||||||
@ -134,6 +133,7 @@ export function useOnboardingFlow(route) {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WHY: step state combines completion, prerequisites, and backend automation; @returns pill text.
|
||||||
function stepPillLabel(step) {
|
function stepPillLabel(step) {
|
||||||
if (isStepDone(step.id)) return "done";
|
if (isStepDone(step.id)) return "done";
|
||||||
if (isStepBlocked(step.id)) return "blocked";
|
if (isStepBlocked(step.id)) return "blocked";
|
||||||
|
|||||||
@ -12,6 +12,12 @@ export function useOnboardingGuides({ isStepDone, isStepBlocked }) {
|
|||||||
const guidePage = ref({});
|
const guidePage = ref({});
|
||||||
const lightboxShot = ref(null);
|
const lightboxShot = ref(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return screenshot groups for a step, honoring configured head/tail limits.
|
||||||
|
*
|
||||||
|
* @param {object} step - Onboarding step definition with optional guide metadata.
|
||||||
|
* @returns {Array<object>} Screenshot groups to render for the guide carousel.
|
||||||
|
*/
|
||||||
function guideGroups(step) {
|
function guideGroups(step) {
|
||||||
if (!step.guide) return [];
|
if (!step.guide) return [];
|
||||||
const service = step.guide.service;
|
const service = step.guide.service;
|
||||||
|
|||||||
@ -11,6 +11,12 @@ import { onMounted, reactive, ref, watch } from "vue";
|
|||||||
* @returns {object} reactive state and event handlers used by the view template.
|
* @returns {object} reactive state and event handlers used by the view template.
|
||||||
*/
|
*/
|
||||||
export function useRequestAccessFlow(route) {
|
export function useRequestAccessFlow(route) {
|
||||||
|
/**
|
||||||
|
* Convert backend request status into copy for the public flow.
|
||||||
|
*
|
||||||
|
* @param {string} value - Raw access request status.
|
||||||
|
* @returns {string} Human-readable status label.
|
||||||
|
*/
|
||||||
function statusLabel(value) {
|
function statusLabel(value) {
|
||||||
const key = (value || "").trim();
|
const key = (value || "").trim();
|
||||||
if (key === "pending_email_verification") return "confirm email";
|
if (key === "pending_email_verification") return "confirm email";
|
||||||
@ -22,6 +28,12 @@ export function useRequestAccessFlow(route) {
|
|||||||
return key || "unknown";
|
return key || "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert backend request status into the matching pill class.
|
||||||
|
*
|
||||||
|
* @param {string} value - Raw access request status.
|
||||||
|
* @returns {string} CSS class for status emphasis.
|
||||||
|
*/
|
||||||
function statusPillClass(value) {
|
function statusPillClass(value) {
|
||||||
const key = (value || "").trim();
|
const key = (value || "").trim();
|
||||||
if (key === "pending_email_verification") return "pill-warn";
|
if (key === "pending_email_verification") return "pill-warn";
|
||||||
@ -87,6 +99,13 @@ export function useRequestAccessFlow(route) {
|
|||||||
availability.blockSubmit = false;
|
availability.blockSubmit = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update username availability UI state from a normalized backend result.
|
||||||
|
*
|
||||||
|
* @param {string} state - Availability state key.
|
||||||
|
* @param {string} detail - Optional human-readable explanation.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
function setAvailability(state, detail = "") {
|
function setAvailability(state, detail = "") {
|
||||||
availability.detail = detail;
|
availability.detail = detail;
|
||||||
availability.blockSubmit = false;
|
availability.blockSubmit = false;
|
||||||
|
|||||||
@ -119,6 +119,12 @@ const chatWindow = ref(null);
|
|||||||
const copied = ref(false);
|
const copied = ref(false);
|
||||||
const conversationIds = reactive({});
|
const conversationIds = reactive({});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return a stable local conversation ID for one AI profile.
|
||||||
|
*
|
||||||
|
* @param {string} profile - Active AI profile key.
|
||||||
|
* @returns {string} Stable conversation identifier persisted in local storage.
|
||||||
|
*/
|
||||||
function ensureConversationId(profile) {
|
function ensureConversationId(profile) {
|
||||||
if (conversationIds[profile]) return conversationIds[profile];
|
if (conversationIds[profile]) return conversationIds[profile];
|
||||||
const key = `atlas-ai-conversation:${profile}`;
|
const key = `atlas-ai-conversation:${profile}`;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user