From 839c2586a23957e26d327871f3ffec6f707bbe41 Mon Sep 17 00:00:00 2001 From: codex Date: Tue, 21 Apr 2026 07:12:35 -0300 Subject: [PATCH] refactor(bstein-home): split access request routes --- .../routes/access_request_onboarding.py | 245 +++ .../access_request_onboarding_policy.py | 80 + .../routes/access_request_state.py | 479 ++++++ .../routes/access_request_status.py | 220 +++ .../routes/access_request_submission.py | 336 +++++ .../atlas_portal/routes/access_requests.py | 1319 +---------------- 6 files changed, 1371 insertions(+), 1308 deletions(-) create mode 100644 backend/atlas_portal/routes/access_request_onboarding.py create mode 100644 backend/atlas_portal/routes/access_request_onboarding_policy.py create mode 100644 backend/atlas_portal/routes/access_request_state.py create mode 100644 backend/atlas_portal/routes/access_request_status.py create mode 100644 backend/atlas_portal/routes/access_request_submission.py diff --git a/backend/atlas_portal/routes/access_request_onboarding.py b/backend/atlas_portal/routes/access_request_onboarding.py new file mode 100644 index 0000000..ed09d9f --- /dev/null +++ b/backend/atlas_portal/routes/access_request_onboarding.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from flask import jsonify, redirect, request + + +def register_access_request_onboarding(app, deps) -> None: + """Register access request onboarding routes.""" + + @app.route("/api/access/request/onboarding/attest", methods=["POST"]) + def request_access_onboarding_attest() -> Any: + if not deps.configured(): + return jsonify({"error": "server not deps.configured"}), 503 + + payload = request.get_json(silent=True) or {} + code = (payload.get("request_code") or payload.get("code") or "").strip() + step = (payload.get("step") or "").strip() + completed = payload.get("completed") + vaultwarden_claim = bool(payload.get("vaultwarden_claim")) + + if not code: + return jsonify({"error": "request_code is required"}), 400 + if step not in deps.ONBOARDING_STEPS: + return jsonify({"error": "invalid step"}), 400 + if step in deps.KEYCLOAK_MANAGED_STEPS: + return jsonify({"error": "step is managed by keycloak"}), 400 + + username = "" + token_groups: set[str] = set() + bearer = request.headers.get("Authorization", "") + if bearer: + parts = bearer.split(None, 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + return jsonify({"error": "invalid token"}), 401 + token = parts[1].strip() + if not token: + return jsonify({"error": "invalid token"}), 401 + try: + claims = deps.oidc_client().verify(token) + except Exception: + return jsonify({"error": "invalid token"}), 401 + username = claims.get("preferred_username") or "" + groups = claims.get("groups") + if isinstance(groups, list): + token_groups = {g.lstrip("/") for g in groups if isinstance(g, str) and g} + + try: + with deps.connect() as conn: + row = conn.execute( + "SELECT username, status, approval_flags, contact_email FROM access_requests WHERE request_code = %s", + (code,), + ).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + if username and (row.get("username") or "") != username: + return jsonify({"error": "forbidden"}), 403 + + status = deps._normalize_status(row.get("status") or "") + if status not in {"awaiting_onboarding", "ready"}: + return jsonify({"error": "onboarding not available"}), 409 + + mark_done = True + if isinstance(completed, bool): + mark_done = completed + + request_username = row.get("username") or "" + approval_flags = deps._normalize_flag_list(row.get("approval_flags")) + contact_email = (row.get("contact_email") or "").strip() + + if mark_done: + prerequisites = deps.ONBOARDING_STEP_PREREQUISITES.get(step, set()) + if prerequisites: + current_completed = deps._completed_onboarding_steps(conn, code, request_username) + missing = sorted(prerequisites - current_completed) + if missing: + return jsonify({"error": "step is blocked", "blocked_by": missing}), 409 + if step in {"vaultwarden_master_password", "vaultwarden_store_temp_password"}: + if not deps._password_rotation_requested(conn, code): + try: + deps._request_keycloak_password_rotation(conn, code, request_username) + except Exception: + return jsonify({"error": "failed to request keycloak password rotation"}), 502 + + if step == "vaultwarden_master_password": + if vaultwarden_claim and not username: + return jsonify({"error": "login required"}), 401 + grandfathered = ( + deps.VAULTWARDEN_GRANDFATHERED_FLAG in approval_flags + or deps.VAULTWARDEN_GRANDFATHERED_FLAG in token_groups + or deps._user_in_group(request_username, deps.VAULTWARDEN_GRANDFATHERED_FLAG) + ) + if vaultwarden_claim and not grandfathered: + return jsonify({"error": "vaultwarden claim not allowed"}), 403 + if vaultwarden_claim and not deps.admin_client().ready(): + return jsonify({"error": "keycloak admin unavailable"}), 503 + if request_username and deps.admin_client().ready(): + try: + now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + if vaultwarden_claim: + recovery_email = deps._resolve_recovery_email(request_username, contact_email) + if not recovery_email: + return jsonify({"error": "recovery email missing"}), 409 + deps.admin_client().set_user_attribute( + request_username, + "vaultwarden_email", + recovery_email, + ) + deps.admin_client().set_user_attribute( + request_username, + "vaultwarden_status", + "grandfathered", + ) + deps.admin_client().set_user_attribute( + request_username, + "vaultwarden_synced_at", + now, + ) + else: + deps.admin_client().set_user_attribute( + request_username, + "vaultwarden_status", + "already_present", + ) + deps.admin_client().set_user_attribute( + request_username, + "vaultwarden_master_password_set_at", + now, + ) + except Exception: + return jsonify({"error": "failed to update vaultwarden status"}), 502 + + conn.execute( + """ + INSERT INTO access_request_onboarding_steps (request_code, step) + VALUES (%s, %s) + ON CONFLICT (request_code, step) DO NOTHING + """, + (code, step), + ) + else: + conn.execute( + "DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s", + (code, step), + ) + + # Re-evaluate completion to update request status to ready if applicable. + status = deps._advance_status(conn, code, request_username, status) + onboarding_payload = deps._onboarding_payload(conn, code, request_username) + except Exception: + return jsonify({"error": "failed to update onboarding"}), 502 + + return jsonify( + { + "ok": True, + "status": status, + "onboarding": onboarding_payload, + } + ) + + @app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"]) + def request_access_onboarding_keycloak_password_rotate() -> Any: + if not deps.configured(): + return jsonify({"error": "server not deps.configured"}), 503 + + payload = request.get_json(silent=True) or {} + code = (payload.get("request_code") or payload.get("code") or "").strip() + if not code: + return jsonify({"error": "request_code is required"}), 400 + + token_username = "" + bearer = request.headers.get("Authorization", "") + if bearer: + parts = bearer.split(None, 1) + if len(parts) != 2 or parts[0].lower() != "bearer": + return jsonify({"error": "invalid token"}), 401 + token = parts[1].strip() + if not token: + return jsonify({"error": "invalid token"}), 401 + try: + claims = deps.oidc_client().verify(token) + except Exception: + return jsonify({"error": "invalid token"}), 401 + token_username = claims.get("preferred_username") or "" + + if not deps.admin_client().ready(): + return jsonify({"error": "keycloak admin unavailable"}), 503 + + try: + with deps.connect() as conn: + row = conn.execute( + "SELECT username, status FROM access_requests WHERE request_code = %s", + (code,), + ).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + request_username = row.get("username") or "" + if token_username and request_username != token_username: + return jsonify({"error": "forbidden"}), 403 + + status = deps._normalize_status(row.get("status") or "") + if status not in {"awaiting_onboarding", "ready"}: + return jsonify({"error": "onboarding not available"}), 409 + + prerequisites = deps.ONBOARDING_STEP_PREREQUISITES.get("keycloak_password_rotated", set()) + if prerequisites: + current_completed = deps._completed_onboarding_steps(conn, code, request_username) + missing = sorted(prerequisites - current_completed) + if missing: + return jsonify({"error": "step is blocked", "blocked_by": missing}), 409 + + user = deps.admin_client().find_user(request_username) or {} + user_id = user.get("id") if isinstance(user, dict) else None + if not isinstance(user_id, str) or not user_id: + return jsonify({"error": "keycloak user not found"}), 409 + + full = deps.admin_client().get_user(user_id) + actions = full.get("requiredActions") + actions_list: list[str] = [] + if isinstance(actions, list): + actions_list = [a for a in actions if isinstance(a, str)] + + rotation_requested = deps._password_rotation_requested(conn, code) + already_rotated = rotation_requested and "UPDATE_PASSWORD" not in actions_list + + if not already_rotated: + if "UPDATE_PASSWORD" not in actions_list: + actions_list.append("UPDATE_PASSWORD") + deps.admin_client().update_user_safe(user_id, {"requiredActions": actions_list}) + if not rotation_requested: + conn.execute( + """ + INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash) + VALUES (%s, %s, NOW()::text) + ON CONFLICT (request_code, artifact) DO NOTHING + """, + (code, deps._KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT), + ) + + onboarding_payload = deps._onboarding_payload(conn, code, request_username) + except Exception: + return jsonify({"error": "failed to request password rotation"}), 502 + + return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload}) diff --git a/backend/atlas_portal/routes/access_request_onboarding_policy.py b/backend/atlas_portal/routes/access_request_onboarding_policy.py new file mode 100644 index 0000000..44234df --- /dev/null +++ b/backend/atlas_portal/routes/access_request_onboarding_policy.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +"""Onboarding step policy for access requests.""" + +ONBOARDING_STEPS: tuple[str, ...] = ( + "vaultwarden_master_password", + "vaultwarden_store_temp_password", + "vaultwarden_browser_extension", + "vaultwarden_mobile_app", + "keycloak_password_rotated", + "element_recovery_key", + "element_mobile_app", + "mail_client_setup", + "nextcloud_web_access", + "nextcloud_mail_integration", + "nextcloud_desktop_app", + "nextcloud_mobile_app", + "budget_encryption_ack", + "firefly_password_rotated", + "firefly_mobile_app", + "wger_password_rotated", + "wger_mobile_app", + "jellyfin_web_access", + "jellyfin_mobile_app", + "jellyfin_tv_setup", +) + +ONBOARDING_OPTIONAL_STEPS: set[str] = { + "element_mobile_app", + "nextcloud_desktop_app", + "nextcloud_mobile_app", + "firefly_mobile_app", + "jellyfin_mobile_app", + "jellyfin_tv_setup", +} +ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = ( + "vaultwarden_master_password", + "vaultwarden_browser_extension", + "vaultwarden_mobile_app", + "keycloak_password_rotated", + "element_recovery_key", + "mail_client_setup", + "nextcloud_web_access", + "nextcloud_mail_integration", + "budget_encryption_ack", + "firefly_password_rotated", + "wger_password_rotated", + "jellyfin_web_access", +) + +KEYCLOAK_MANAGED_STEPS: set[str] = { + "keycloak_password_rotated", + "nextcloud_mail_integration", +} +_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at" + +ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = { + "vaultwarden_master_password": set(), + "vaultwarden_store_temp_password": {"vaultwarden_master_password"}, + "vaultwarden_browser_extension": {"vaultwarden_master_password"}, + "vaultwarden_mobile_app": {"vaultwarden_master_password"}, + "keycloak_password_rotated": {"vaultwarden_master_password"}, + "element_recovery_key": {"keycloak_password_rotated"}, + "element_mobile_app": {"element_recovery_key"}, + "mail_client_setup": {"vaultwarden_master_password"}, + "nextcloud_web_access": {"vaultwarden_master_password"}, + "nextcloud_mail_integration": {"nextcloud_web_access"}, + "nextcloud_desktop_app": {"nextcloud_web_access"}, + "nextcloud_mobile_app": {"nextcloud_web_access"}, + "budget_encryption_ack": {"nextcloud_mail_integration"}, + "firefly_password_rotated": {"element_recovery_key"}, + "wger_password_rotated": {"firefly_password_rotated"}, + "wger_mobile_app": {"wger_password_rotated"}, + "jellyfin_web_access": {"vaultwarden_master_password"}, + "jellyfin_mobile_app": {"jellyfin_web_access"}, + "jellyfin_tv_setup": {"jellyfin_web_access"}, +} + +VAULTWARDEN_GRANDFATHERED_FLAG = "vaultwarden_grandfathered" +_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready", "grandfathered"} diff --git a/backend/atlas_portal/routes/access_request_state.py b/backend/atlas_portal/routes/access_request_state.py new file mode 100644 index 0000000..d9e280c --- /dev/null +++ b/backend/atlas_portal/routes/access_request_state.py @@ -0,0 +1,479 @@ +from __future__ import annotations + +from datetime import datetime, timezone +import hashlib +import hmac +import re +import secrets +import string +from typing import Any +from urllib.parse import quote + +from flask import request + +from .. import ariadne_client +from ..db import connect, configured +from ..keycloak import admin_client, oidc_client +from ..mailer import MailerError, access_request_verification_body, send_text_email +from ..rate_limit import rate_limit_allow +from ..provisioning import provision_access_request, provision_tasks_complete +from .. import settings +from .access_request_onboarding_policy import ( + KEYCLOAK_MANAGED_STEPS, + ONBOARDING_OPTIONAL_STEPS, + ONBOARDING_REQUIRED_STEPS, + ONBOARDING_STEP_PREREQUISITES, + ONBOARDING_STEPS, + VAULTWARDEN_GRANDFATHERED_FLAG, + _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT, + _VAULTWARDEN_READY_STATUSES, +) + +def _extract_request_payload() -> tuple[str, str, str, str, str]: + payload = request.get_json(silent=True) or {} + username = (payload.get("username") or "").strip() + email = (payload.get("email") or "").strip() + note = (payload.get("note") or "").strip() + first_name = (payload.get("first_name") or "").strip() + last_name = (payload.get("last_name") or "").strip() + return username, email, note, first_name, last_name + + +def _normalize_name(value: str) -> str: + return " ".join(value.strip().split()) + + +def _validate_name(value: str, *, label: str, required: bool) -> str | None: + cleaned = _normalize_name(value) + if not cleaned: + return f"{label} is required" if required else None + if len(cleaned) > 80: + return f"{label} must be 1-80 characters" + if any(ch in "\r\n\t" for ch in cleaned): + return f"{label} contains invalid whitespace" + return None + + +def _validate_username(username: str) -> str | None: + if not username: + return "username is required" + if len(username) < 3 or len(username) > 32: + return "username must be 3-32 characters" + if not re.fullmatch(r"[a-zA-Z0-9._-]+", username): + return "username contains invalid characters" + return None + + +def _random_request_code(username: str) -> str: + suffix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(10)) + return f"{username}~{suffix}" + + +def _client_ip() -> str: + xff = (request.headers.get("X-Forwarded-For") or "").strip() + if xff: + return xff.split(",", 1)[0].strip() or "unknown" + x_real_ip = (request.headers.get("X-Real-IP") or "").strip() + if x_real_ip: + return x_real_ip + return request.remote_addr or "unknown" + + +EMAIL_VERIFY_PENDING_STATUS = "pending_email_verification" + + +def _hash_verification_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def _verify_url(request_code: str, token: str) -> str: + base = settings.PORTAL_PUBLIC_BASE_URL.rstrip("/") + return f"{base}/api/access/request/verify-link?code={quote(request_code)}&token={quote(token)}" + + +def _send_verification_email(*, request_code: str, email: str, token: str) -> None: + verify_url = _verify_url(request_code, token) + send_text_email( + to_addr=email, + subject="Atlas: confirm your email", + body=access_request_verification_body(request_code=request_code, verify_url=verify_url), + ) + + +class VerificationError(Exception): + def __init__(self, status_code: int, message: str) -> None: + super().__init__(message) + self.status_code = status_code + self.message = message + + +def _verify_request(conn, code: str, token: str) -> str: + row = conn.execute( + """ + SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at + FROM access_requests + WHERE request_code = %s + """, + (code,), + ).fetchone() + if not row: + raise VerificationError(404, "not found") + + status = _normalize_status(row.get("status") or "") + if status != EMAIL_VERIFY_PENDING_STATUS: + return status + + stored_hash = str(row.get("email_verification_token_hash") or "") + if not stored_hash: + raise VerificationError(409, "verification token missing") + + provided_hash = _hash_verification_token(token) + if not hmac.compare_digest(stored_hash, provided_hash): + raise VerificationError(401, "invalid token") + + sent_at = row.get("email_verification_sent_at") + if isinstance(sent_at, datetime): + now = datetime.now(timezone.utc) + if sent_at.tzinfo is None: + sent_at = sent_at.replace(tzinfo=timezone.utc) + age_sec = (now - sent_at).total_seconds() + if age_sec > settings.ACCESS_REQUEST_EMAIL_VERIFY_TTL_SEC: + raise VerificationError(410, "verification token expired") + + conn.execute( + """ + UPDATE access_requests + SET status = 'pending', + email_verified_at = NOW(), + email_verification_token_hash = NULL + WHERE request_code = %s AND status = %s + """, + (code, EMAIL_VERIFY_PENDING_STATUS), + ) + return "pending" + + +def _normalize_status(status: str) -> str: + cleaned = (status or "").strip().lower() + if cleaned == "approved": + return "accounts_building" + return cleaned or "unknown" + + +def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]: + rows = conn.execute( + "SELECT step FROM access_request_onboarding_steps WHERE request_code = %s", + (request_code,), + ).fetchall() + completed: set[str] = set() + for row in rows: + step = row.get("step") if isinstance(row, dict) else None + if isinstance(step, str) and step: + completed.add(step) + return completed + + +def _normalize_flag_list(raw: Any) -> set[str]: + if isinstance(raw, list): + return {item for item in raw if isinstance(item, str) and item} + if isinstance(raw, str) and raw: + return {raw} + return set() + + +def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], str]: + row = conn.execute( + "SELECT approval_flags, contact_email FROM access_requests WHERE request_code = %s", + (request_code,), + ).fetchone() + if not row: + return set(), "" + flags = _normalize_flag_list(row.get("approval_flags")) + email = row.get("contact_email") if isinstance(row, dict) else "" + return flags, (email or "").strip() + + +def _user_in_group(username: str, group_name: str) -> bool: + if not username or not group_name: + return False + if not admin_client().ready(): + return False + try: + user = admin_client().find_user(username) or {} + user_id = user.get("id") if isinstance(user, dict) else None + if not isinstance(user_id, str) or not user_id: + return False + groups = admin_client().list_user_groups(user_id) + except Exception: + return False + return group_name in groups + + +def _vaultwarden_grandfathered(conn, request_code: str, username: str) -> tuple[bool, str]: + flags, contact_email = _fetch_request_flags_and_email(conn, request_code) + if VAULTWARDEN_GRANDFATHERED_FLAG in flags: + return True, contact_email + if _user_in_group(username, VAULTWARDEN_GRANDFATHERED_FLAG): + return True, contact_email + return False, contact_email + + +def _resolve_recovery_email(username: str, fallback: str) -> str: + if username and admin_client().ready(): + try: + user = admin_client().find_user(username) or {} + user_id = user.get("id") if isinstance(user, dict) else None + if isinstance(user_id, str) and user_id: + full = admin_client().get_user(user_id) + email = full.get("email") + if isinstance(email, str) and email.strip(): + return email.strip() + except Exception: + pass + return (fallback or "").strip() + + +def _password_rotation_requested(conn, request_code: str) -> bool: + row = conn.execute( + """ + SELECT 1 + FROM access_request_onboarding_artifacts + WHERE request_code = %s AND artifact = %s + LIMIT 1 + """, + (request_code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT), + ).fetchone() + return bool(row) + + +def _request_keycloak_password_rotation(conn, request_code: str, username: str) -> None: + if not username: + raise ValueError("username missing") + if not admin_client().ready(): + raise RuntimeError("keycloak admin unavailable") + + user = admin_client().find_user(username) or {} + user_id = user.get("id") if isinstance(user, dict) else None + if not isinstance(user_id, str) or not user_id: + raise RuntimeError("keycloak user not found") + + full = admin_client().get_user(user_id) + actions = full.get("requiredActions") + actions_list: list[str] = [] + if isinstance(actions, list): + actions_list = [a for a in actions if isinstance(a, str)] + if "UPDATE_PASSWORD" not in actions_list: + actions_list.append("UPDATE_PASSWORD") + admin_client().update_user_safe(user_id, {"requiredActions": actions_list}) + + conn.execute( + """ + INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash) + VALUES (%s, %s, NOW()::text) + ON CONFLICT (request_code, artifact) DO NOTHING + """, + (request_code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT), + ) + + +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 _vaultwarden_status_for_user(username: str) -> str: + if not username: + return "" + if not admin_client().ready(): + return "" + try: + user = admin_client().find_user(username) or {} + user_id = user.get("id") if isinstance(user, dict) else None + if not isinstance(user_id, str) or not user_id: + return "" + full = admin_client().get_user(user_id) + attrs = full.get("attributes") if isinstance(full, dict) else {} + return _extract_attr(attrs, "vaultwarden_status") + except Exception: + return "" + + +def _auto_completed_service_steps(attrs: Any) -> set[str]: + completed: set[str] = set() + if not isinstance(attrs, dict): + return completed + + vaultwarden_status = _extract_attr(attrs, "vaultwarden_status") + vaultwarden_master = _extract_attr(attrs, "vaultwarden_master_password_set_at") + if vaultwarden_master or vaultwarden_status in _VAULTWARDEN_READY_STATUSES: + completed.add("vaultwarden_master_password") + + nextcloud_synced_at = _extract_attr(attrs, "nextcloud_mail_synced_at") + if nextcloud_synced_at: + completed.add("nextcloud_mail_integration") + + firefly_rotated_at = _extract_attr(attrs, "firefly_password_rotated_at") + if firefly_rotated_at: + completed.add("firefly_password_rotated") + + wger_rotated_at = _extract_attr(attrs, "wger_password_rotated_at") + if wger_rotated_at: + completed.add("wger_password_rotated") + + return completed + + +def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]: + if not username: + return set() + if not admin_client().ready(): + return set() + if not request_code: + return set() + + completed: set[str] = set() + try: + user = admin_client().find_user(username) or {} + user_id = user.get("id") if isinstance(user, dict) else None + if not isinstance(user_id, str) or not user_id: + return set() + + full = {} + try: + full = admin_client().get_user(user_id) + except Exception: + full = user if isinstance(user, dict) else {} + + attrs = full.get("attributes") if isinstance(full, dict) else {} + completed |= _auto_completed_service_steps(attrs) + + actions = full.get("requiredActions") + required_actions: set[str] = set() + actions_list: list[str] = [] + if isinstance(actions, list): + actions_list = [a for a in actions if isinstance(a, str)] + required_actions = set(actions_list) + + if _password_rotation_requested(conn, request_code) and "UPDATE_PASSWORD" not in required_actions: + completed.add("keycloak_password_rotated") + + # Backfill: earlier accounts were created with CONFIGURE_TOTP as a required action, + # which forces users to enroll MFA at first login. We no longer require that, so + # remove it if present. + if "CONFIGURE_TOTP" in required_actions: + try: + admin_client().update_user_safe( + user_id, + {"requiredActions": [a for a in actions_list if a != "CONFIGURE_TOTP"]}, + ) + except Exception: + pass + except Exception: + return set() + + return completed + + +def _completed_onboarding_steps(conn, request_code: str, username: str) -> set[str]: + completed = _fetch_completed_onboarding_steps(conn, request_code) + return completed | _auto_completed_keycloak_steps(conn, request_code, username) + + +def _automation_ready(conn, request_code: str, username: str) -> bool: + if not username: + return False + if not admin_client().ready(): + return False + + # Prefer task-based readiness when we have task rows for the request. + task_row = conn.execute( + "SELECT 1 FROM access_request_tasks WHERE request_code = %s LIMIT 1", + (request_code,), + ).fetchone() + if task_row: + return provision_tasks_complete(conn, request_code) + + # Fallback for legacy requests: confirm user exists and has a mail app password. + try: + user = admin_client().find_user(username) + if not user: + return False + user_id = user.get("id") if isinstance(user, dict) else None + if not user_id: + return False + full = admin_client().get_user(str(user_id)) + attrs = full.get("attributes") or {} + if not isinstance(attrs, dict): + return False + raw_pw = attrs.get("mailu_app_password") + if isinstance(raw_pw, list): + return bool(raw_pw and isinstance(raw_pw[0], str) and raw_pw[0]) + return bool(isinstance(raw_pw, str) and raw_pw) + except Exception: + return False + + +def _advance_status(conn, request_code: str, username: str, status: str) -> str: + status = _normalize_status(status) + + if status == "accounts_building" and _automation_ready(conn, request_code, username): + conn.execute( + "UPDATE access_requests SET status = 'awaiting_onboarding' WHERE request_code = %s AND status = 'accounts_building'", + (request_code,), + ) + return "awaiting_onboarding" + + if status == "awaiting_onboarding": + completed = _completed_onboarding_steps(conn, request_code, username) + required_steps = set(ONBOARDING_REQUIRED_STEPS) + grandfathered, _ = _vaultwarden_grandfathered(conn, request_code, username) + vaultwarden_status = _vaultwarden_status_for_user(username) + if grandfathered and vaultwarden_status == "grandfathered": + required_steps.add("vaultwarden_store_temp_password") + if required_steps.issubset(completed): + conn.execute( + "UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'", + (request_code,), + ) + return "ready" + + return status + + +def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]: + completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username)) + password_rotation_requested = _password_rotation_requested(conn, request_code) + grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username) + recovery_email = _resolve_recovery_email(username, contact_email) if grandfathered else "" + vaultwarden_status = _vaultwarden_status_for_user(username) + vaultwarden_matched = grandfathered and vaultwarden_status == "grandfathered" + required_steps = list(ONBOARDING_REQUIRED_STEPS) + if vaultwarden_matched: + required_steps.append("vaultwarden_store_temp_password") + return { + "required_steps": required_steps, + "optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS), + "completed_steps": completed_steps, + "keycloak": { + "password_rotation_requested": password_rotation_requested, + }, + "vaultwarden": { + "grandfathered": grandfathered, + "recovery_email": recovery_email, + "matched": vaultwarden_matched, + }, + } + + +# Keep the historical access_requests module patch surface intact for tests and +# callers while the route handlers live in smaller focused modules. +__all__ = [name for name in globals() if not name.startswith("__")] diff --git a/backend/atlas_portal/routes/access_request_status.py b/backend/atlas_portal/routes/access_request_status.py new file mode 100644 index 0000000..2dbb61f --- /dev/null +++ b/backend/atlas_portal/routes/access_request_status.py @@ -0,0 +1,220 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from flask import jsonify, redirect, request + + +def register_access_request_status(app, deps) -> None: + """Register access request status routes.""" + + @app.route("/api/access/request/status", methods=["POST"]) + def request_access_status() -> Any: + if not deps.settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not deps.configured(): + return jsonify({"error": "server not deps.configured"}), 503 + + ip = deps._client_ip() + if not deps.rate_limit_allow( + ip, + key="access_request_status", + limit=deps.settings.ACCESS_REQUEST_STATUS_RATE_LIMIT, + window_sec=deps.settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC, + ): + return jsonify({"error": "rate limited"}), 429 + + payload = request.get_json(silent=True) or {} + code = (payload.get("request_code") or payload.get("code") or "").strip() + reveal_initial_password = bool( + payload.get("reveal_initial_password") or payload.get("reveal_password") + ) + if not code: + return jsonify({"error": "request_code is required"}), 400 + + # Additional per-code limiter to avoid global NAT rate-limit blowups. + if not deps.rate_limit_allow( + f"{ip}:{code}", + key="access_request_status_code", + limit=max(20, deps.settings.ACCESS_REQUEST_STATUS_RATE_LIMIT), + window_sec=deps.settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC, + ): + return jsonify({"error": "rate limited"}), 429 + + try: + with deps.connect() as conn: + row = conn.execute( + """ + SELECT status, + username, + initial_password, + initial_password_revealed_at, + email_verified_at + FROM access_requests + WHERE request_code = %s + """, + (code,), + ).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + current_status = deps._normalize_status(row.get("status") or "") + if current_status == "accounts_building" and not deps.ariadne_client.enabled(): + try: + deps.provision_access_request(code) + except Exception: + pass + row = conn.execute( + """ + SELECT status, + username, + initial_password, + initial_password_revealed_at, + email_verified_at + FROM access_requests + WHERE request_code = %s + """, + (code,), + ).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + + status = deps._advance_status(conn, code, row.get("username") or "", row.get("status") or "") + response: dict[str, Any] = { + "ok": True, + "status": status, + "username": row.get("username") or "", + "email_verified": bool(row.get("email_verified_at")), + } + task_rows = conn.execute( + """ + SELECT task, status, detail, updated_at + FROM access_request_tasks + WHERE request_code = %s + ORDER BY task + """, + (code,), + ).fetchall() + if task_rows: + tasks: list[dict[str, Any]] = [] + blocked = False + for task_row in task_rows: + task_name = task_row.get("task") if isinstance(task_row, dict) else None + task_status = task_row.get("status") if isinstance(task_row, dict) else None + detail = task_row.get("detail") if isinstance(task_row, dict) else None + updated_at = task_row.get("updated_at") if isinstance(task_row, dict) else None + + if isinstance(task_status, str) and task_status == "error": + blocked = True + + task_payload: dict[str, Any] = { + "task": task_name or "", + "status": task_status or "", + } + if isinstance(detail, str) and detail: + task_payload["detail"] = detail + if isinstance(updated_at, datetime): + task_payload["updated_at"] = updated_at.astimezone(timezone.utc).isoformat() + tasks.append(task_payload) + + response["tasks"] = tasks + response["automation_complete"] = deps.provision_tasks_complete(conn, code) + response["blocked"] = blocked + if status in {"awaiting_onboarding", "ready"}: + revealed_at = row.get("initial_password_revealed_at") + if isinstance(revealed_at, datetime): + response["initial_password_revealed_at"] = revealed_at.astimezone(timezone.utc).isoformat() + if reveal_initial_password: + password = row.get("initial_password") + if isinstance(password, str) and password and revealed_at is None: + response["initial_password"] = password + conn.execute( + "UPDATE access_requests SET initial_password_revealed_at = NOW() WHERE request_code = %s AND initial_password_revealed_at IS NULL", + (code,), + ) + if status in {"awaiting_onboarding", "ready"}: + response["onboarding_url"] = f"/onboarding?code={code}" + if status in {"awaiting_onboarding", "ready"}: + response["onboarding"] = deps._onboarding_payload(conn, code, row.get("username") or "") + return jsonify(response) + except Exception: + return jsonify({"error": "failed to load status"}), 502 + + @app.route("/api/access/request/retry", methods=["POST"]) + def request_access_retry() -> Any: + if not deps.settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not deps.configured(): + return jsonify({"error": "server not deps.configured"}), 503 + + ip = deps._client_ip() + if not deps.rate_limit_allow( + ip, + key="access_request_retry", + limit=deps.settings.ACCESS_REQUEST_STATUS_RATE_LIMIT, + window_sec=deps.settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC, + ): + return jsonify({"error": "rate limited"}), 429 + + payload = request.get_json(silent=True) or {} + code = (payload.get("request_code") or payload.get("code") or "").strip() + tasks = payload.get("tasks") + task_list = [task for task in tasks if isinstance(task, str) and task.strip()] if isinstance(tasks, list) else [] + if not code: + return jsonify({"error": "request_code is required"}), 400 + + if deps.ariadne_client.enabled(): + retry_payload = {"tasks": task_list} if task_list else None + return deps.ariadne_client.proxy( + "POST", + f"/api/access/requests/{code}/retry", + payload=retry_payload, + ) + + try: + with deps.connect() as conn: + row = conn.execute( + "SELECT status FROM access_requests WHERE request_code = %s", + (code,), + ).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + status = row.get("status") or "" + if status not in {"accounts_building", "approved"}: + return jsonify({"error": "request not retryable"}), 409 + conn.execute( + "UPDATE access_requests SET provision_attempted_at = NULL WHERE request_code = %s", + (code,), + ) + if task_list: + conn.execute( + """ + UPDATE access_request_tasks + SET status = 'pending', + detail = 'retry requested', + updated_at = NOW() + WHERE request_code = %s + AND task = ANY(%s::text[]) + AND status = 'error' + """, + (code, task_list), + ) + else: + conn.execute( + """ + UPDATE access_request_tasks + SET status = 'pending', + detail = 'retry requested', + updated_at = NOW() + WHERE request_code = %s AND status = 'error' + """, + (code,), + ) + except Exception: + return jsonify({"error": "failed to retry request"}), 502 + + try: + deps.provision_access_request(code) + except Exception: + pass + return jsonify({"ok": True, "status": "accounts_building"}) diff --git a/backend/atlas_portal/routes/access_request_submission.py b/backend/atlas_portal/routes/access_request_submission.py new file mode 100644 index 0000000..956b670 --- /dev/null +++ b/backend/atlas_portal/routes/access_request_submission.py @@ -0,0 +1,336 @@ +from __future__ import annotations + +import secrets +from typing import Any +from urllib.parse import quote + +from flask import jsonify, redirect, request +import psycopg + + +def register_access_request_submission(app, deps) -> None: + """Register access request submission routes.""" + + @app.route("/api/access/request/availability", methods=["GET"]) + def request_access_availability() -> Any: + if not deps.settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not deps.configured(): + return jsonify({"error": "server not deps.configured"}), 503 + + username = (request.args.get("username") or "").strip() + error = deps._validate_username(username) + if error: + return jsonify({"available": False, "reason": "invalid", "detail": error}) + + if deps.admin_client().ready() and deps.admin_client().find_user(username): + return jsonify({"available": False, "reason": "exists", "detail": "username already exists"}) + + try: + with deps.connect() as conn: + existing = conn.execute( + """ + SELECT status + FROM access_requests + WHERE username = %s + ORDER BY created_at DESC + LIMIT 1 + """, + (username,), + ).fetchone() + except Exception: + return jsonify({"error": "failed to check availability"}), 502 + + if existing: + status = str(existing.get("status") or "") + return jsonify( + { + "available": False, + "reason": "requested", + "status": deps._normalize_status(status), + } + ) + + return jsonify({"available": True}) + + @app.route("/api/access/request", methods=["POST"]) + def request_access() -> Any: + if not deps.settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not deps.configured(): + return jsonify({"error": "server not deps.configured"}), 503 + + ip = deps._client_ip() + username, email, note, first_name, last_name = deps._extract_request_payload() + first_name = deps._normalize_name(first_name) + last_name = deps._normalize_name(last_name) + + rate_key = ip + if username: + rate_key = f"{ip}:{username}" + if not deps.rate_limit_allow( + rate_key, + key="access_request_submit", + limit=deps.settings.ACCESS_REQUEST_SUBMIT_RATE_LIMIT, + window_sec=deps.settings.ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC, + ): + return jsonify({"error": "rate limited"}), 429 + + username_error = deps._validate_username(username) + if username_error: + return jsonify({"error": username_error}), 400 + name_error = deps._validate_name(first_name, label="first name", required=False) + if name_error: + return jsonify({"error": name_error}), 400 + name_error = deps._validate_name(last_name, label="last name", required=True) + if name_error: + return jsonify({"error": name_error}), 400 + if not email: + return jsonify({"error": "email is required"}), 400 + if "@" not in email: + return jsonify({"error": "invalid email"}), 400 + email_lower = email.lower() + if email_lower.endswith(f"@{deps.settings.MAILU_DOMAIN.lower()}") and ( + email_lower not in deps.settings.ACCESS_REQUEST_INTERNAL_EMAIL_ALLOWLIST + ): + return jsonify({"error": "email must be an external address"}), 400 + + if deps.admin_client().ready() and deps.admin_client().find_user(username): + return jsonify({"error": "username already exists"}), 409 + if deps.admin_client().ready() and deps.admin_client().find_user_by_email(email): + return jsonify({"error": "email is already associated with an existing Atlas account"}), 409 + + try: + with deps.connect() as conn: + existing = conn.execute( + """ + SELECT request_code, status + FROM access_requests + WHERE username = %s AND status IN (%s, 'pending') + ORDER BY created_at DESC + LIMIT 1 + """, + (username, deps.EMAIL_VERIFY_PENDING_STATUS), + ).fetchone() + if existing: + existing_status = str(existing.get("status") or "") + request_code = str(existing.get("request_code") or "") + if existing_status != deps.EMAIL_VERIFY_PENDING_STATUS: + return jsonify({"ok": True, "request_code": request_code, "status": existing_status}) + + token = secrets.token_urlsafe(24) + token_hash = deps._hash_verification_token(token) + conn.execute( + """ + UPDATE access_requests + SET contact_email = %s, + note = %s, + first_name = %s, + last_name = %s, + email_verification_token_hash = %s, + email_verification_sent_at = NOW(), + email_verified_at = NULL + WHERE request_code = %s AND status = %s + """, + ( + email, + note or None, + first_name or None, + last_name or None, + token_hash, + request_code, + deps.EMAIL_VERIFY_PENDING_STATUS, + ), + ) + + try: + deps._send_verification_email(request_code=request_code, email=email, token=token) + except deps.MailerError: + return ( + jsonify({"error": "failed to send verification email", "request_code": request_code}), + 502, + ) + return jsonify({"ok": True, "request_code": request_code, "status": deps.EMAIL_VERIFY_PENDING_STATUS}) + + request_code = deps._random_request_code(username) + token = secrets.token_urlsafe(24) + token_hash = deps._hash_verification_token(token) + try: + conn.execute( + """ + INSERT INTO access_requests + (request_code, username, contact_email, note, first_name, last_name, status, + email_verification_token_hash, email_verification_sent_at) + VALUES + (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) + """, + ( + request_code, + username, + email, + note or None, + first_name or None, + last_name or None, + deps.EMAIL_VERIFY_PENDING_STATUS, + token_hash, + ), + ) + except psycopg.errors.UniqueViolation: + conn.rollback() + existing = conn.execute( + """ + SELECT request_code, status + FROM access_requests + WHERE username = %s AND status IN (%s, 'pending') + ORDER BY created_at DESC + LIMIT 1 + """, + (username, deps.EMAIL_VERIFY_PENDING_STATUS), + ).fetchone() + if not existing: + raise + return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]}) + + try: + deps._send_verification_email(request_code=request_code, email=email, token=token) + except deps.MailerError: + return jsonify({"error": "failed to send verification email", "request_code": request_code}), 502 + except Exception: + return jsonify({"error": "failed to submit request"}), 502 + + return jsonify({"ok": True, "request_code": request_code, "status": deps.EMAIL_VERIFY_PENDING_STATUS}) + + @app.route("/api/access/request/verify", methods=["POST"]) + def request_access_verify() -> Any: + if not deps.settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not deps.configured(): + return jsonify({"error": "server not deps.configured"}), 503 + + ip = deps._client_ip() + if not deps.rate_limit_allow( + ip, + key="access_request_verify", + limit=60, + window_sec=60, + ): + return jsonify({"error": "rate limited"}), 429 + + payload = request.get_json(silent=True) or {} + code = (payload.get("request_code") or payload.get("code") or "").strip() + reveal_initial_password = bool( + payload.get("reveal_initial_password") or payload.get("reveal_password") + ) + token = (payload.get("token") or payload.get("verify") or "").strip() + if not code: + return jsonify({"error": "request_code is required"}), 400 + if not token: + return jsonify({"error": "token is required"}), 400 + + if not deps.rate_limit_allow( + f"{ip}:{code}", + key="access_request_verify_code", + limit=30, + window_sec=60, + ): + return jsonify({"error": "rate limited"}), 429 + + try: + with deps.connect() as conn: + status = deps._verify_request(conn, code, token) + return jsonify({"ok": True, "status": status}) + except deps.VerificationError as exc: + return jsonify({"error": exc.message}), exc.status_code + except Exception: + return jsonify({"error": "failed to verify"}), 502 + + @app.route("/api/access/request/verify-link", methods=["GET"]) + def request_access_verify_link() -> Any: + if not deps.settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not deps.configured(): + return jsonify({"error": "server not deps.configured"}), 503 + + code = (request.args.get("code") or "").strip() + token = (request.args.get("token") or "").strip() + if not code or not token: + return redirect(f"/request-access?code={quote(code)}&verify_error=missing+token") + + try: + with deps.connect() as conn: + deps._verify_request(conn, code, token) + return redirect(f"/request-access?code={quote(code)}&verified=1") + except deps.VerificationError as exc: + return redirect(f"/request-access?code={quote(code)}&verify_error={quote(exc.message)}") + except Exception: + return redirect(f"/request-access?code={quote(code)}&verify_error=failed+to+verify") + + @app.route("/api/access/request/resend", methods=["POST"]) + def request_access_resend() -> Any: + if not deps.settings.ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not deps.configured(): + return jsonify({"error": "server not deps.configured"}), 503 + + ip = deps._client_ip() + if not deps.rate_limit_allow( + ip, + key="access_request_resend", + limit=30, + window_sec=60, + ): + return jsonify({"error": "rate limited"}), 429 + + payload = request.get_json(silent=True) or {} + code = (payload.get("request_code") or payload.get("code") or "").strip() + if not code: + return jsonify({"error": "request_code is required"}), 400 + + if not deps.rate_limit_allow( + f"{ip}:{code}", + key="access_request_resend_code", + limit=10, + window_sec=300, + ): + return jsonify({"error": "rate limited"}), 429 + + try: + with deps.connect() as conn: + row = conn.execute( + """ + SELECT status, contact_email + FROM access_requests + WHERE request_code = %s + """, + (code,), + ).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + + status = deps._normalize_status(row.get("status") or "") + if status != deps.EMAIL_VERIFY_PENDING_STATUS: + return jsonify({"ok": True, "status": status}) + + email = str(row.get("contact_email") or "").strip() + if not email: + return jsonify({"error": "missing email"}), 409 + + token = secrets.token_urlsafe(24) + token_hash = deps._hash_verification_token(token) + conn.execute( + """ + UPDATE access_requests + SET email_verification_token_hash = %s, + email_verification_sent_at = NOW() + WHERE request_code = %s AND status = %s + """, + (token_hash, code, deps.EMAIL_VERIFY_PENDING_STATUS), + ) + + try: + deps._send_verification_email(request_code=code, email=email, token=token) + except deps.MailerError: + return jsonify({"error": "failed to send verification email", "request_code": code}), 502 + return jsonify({"ok": True, "status": deps.EMAIL_VERIFY_PENDING_STATUS}) + except Exception: + return jsonify({"error": "failed to resend verification"}), 502 diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index c55908e..b3c3063 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -1,1316 +1,19 @@ from __future__ import annotations -from datetime import datetime, timezone -import hashlib -import hmac -import re -import secrets -import string -from typing import Any -from urllib.parse import quote +"""Access request route registration facade.""" -from flask import jsonify, request, redirect +import sys -import psycopg - -from .. import ariadne_client -from ..db import connect, configured -from ..keycloak import admin_client, oidc_client -from ..mailer import MailerError, access_request_verification_body, send_text_email -from ..rate_limit import rate_limit_allow -from ..provisioning import provision_access_request, provision_tasks_complete -from .. import settings - - -def _extract_request_payload() -> tuple[str, str, str, str, str]: - payload = request.get_json(silent=True) or {} - username = (payload.get("username") or "").strip() - email = (payload.get("email") or "").strip() - note = (payload.get("note") or "").strip() - first_name = (payload.get("first_name") or "").strip() - last_name = (payload.get("last_name") or "").strip() - return username, email, note, first_name, last_name - - -def _normalize_name(value: str) -> str: - return " ".join(value.strip().split()) - - -def _validate_name(value: str, *, label: str, required: bool) -> str | None: - cleaned = _normalize_name(value) - if not cleaned: - return f"{label} is required" if required else None - if len(cleaned) > 80: - return f"{label} must be 1-80 characters" - if any(ch in "\r\n\t" for ch in cleaned): - return f"{label} contains invalid whitespace" - return None - - -def _validate_username(username: str) -> str | None: - if not username: - return "username is required" - if len(username) < 3 or len(username) > 32: - return "username must be 3-32 characters" - if not re.fullmatch(r"[a-zA-Z0-9._-]+", username): - return "username contains invalid characters" - return None - - -def _random_request_code(username: str) -> str: - suffix = "".join(secrets.choice(string.ascii_uppercase + string.digits) for _ in range(10)) - return f"{username}~{suffix}" - - -def _client_ip() -> str: - xff = (request.headers.get("X-Forwarded-For") or "").strip() - if xff: - return xff.split(",", 1)[0].strip() or "unknown" - x_real_ip = (request.headers.get("X-Real-IP") or "").strip() - if x_real_ip: - return x_real_ip - return request.remote_addr or "unknown" - - -EMAIL_VERIFY_PENDING_STATUS = "pending_email_verification" - - -def _hash_verification_token(token: str) -> str: - return hashlib.sha256(token.encode("utf-8")).hexdigest() - - -def _verify_url(request_code: str, token: str) -> str: - base = settings.PORTAL_PUBLIC_BASE_URL.rstrip("/") - return f"{base}/api/access/request/verify-link?code={quote(request_code)}&token={quote(token)}" - - -def _send_verification_email(*, request_code: str, email: str, token: str) -> None: - verify_url = _verify_url(request_code, token) - send_text_email( - to_addr=email, - subject="Atlas: confirm your email", - body=access_request_verification_body(request_code=request_code, verify_url=verify_url), - ) - - -class VerificationError(Exception): - def __init__(self, status_code: int, message: str) -> None: - super().__init__(message) - self.status_code = status_code - self.message = message - - -def _verify_request(conn, code: str, token: str) -> str: - row = conn.execute( - """ - SELECT status, email_verification_token_hash, email_verification_sent_at, email_verified_at - FROM access_requests - WHERE request_code = %s - """, - (code,), - ).fetchone() - if not row: - raise VerificationError(404, "not found") - - status = _normalize_status(row.get("status") or "") - if status != EMAIL_VERIFY_PENDING_STATUS: - return status - - stored_hash = str(row.get("email_verification_token_hash") or "") - if not stored_hash: - raise VerificationError(409, "verification token missing") - - provided_hash = _hash_verification_token(token) - if not hmac.compare_digest(stored_hash, provided_hash): - raise VerificationError(401, "invalid token") - - sent_at = row.get("email_verification_sent_at") - if isinstance(sent_at, datetime): - now = datetime.now(timezone.utc) - if sent_at.tzinfo is None: - sent_at = sent_at.replace(tzinfo=timezone.utc) - age_sec = (now - sent_at).total_seconds() - if age_sec > settings.ACCESS_REQUEST_EMAIL_VERIFY_TTL_SEC: - raise VerificationError(410, "verification token expired") - - conn.execute( - """ - UPDATE access_requests - SET status = 'pending', - email_verified_at = NOW(), - email_verification_token_hash = NULL - WHERE request_code = %s AND status = %s - """, - (code, EMAIL_VERIFY_PENDING_STATUS), - ) - return "pending" - - -ONBOARDING_STEPS: tuple[str, ...] = ( - "vaultwarden_master_password", - "vaultwarden_store_temp_password", - "vaultwarden_browser_extension", - "vaultwarden_mobile_app", - "keycloak_password_rotated", - "element_recovery_key", - "element_mobile_app", - "mail_client_setup", - "nextcloud_web_access", - "nextcloud_mail_integration", - "nextcloud_desktop_app", - "nextcloud_mobile_app", - "budget_encryption_ack", - "firefly_password_rotated", - "firefly_mobile_app", - "wger_password_rotated", - "wger_mobile_app", - "jellyfin_web_access", - "jellyfin_mobile_app", - "jellyfin_tv_setup", -) - -ONBOARDING_OPTIONAL_STEPS: set[str] = { - "element_mobile_app", - "nextcloud_desktop_app", - "nextcloud_mobile_app", - "firefly_mobile_app", - "jellyfin_mobile_app", - "jellyfin_tv_setup", -} -ONBOARDING_REQUIRED_STEPS: tuple[str, ...] = ( - "vaultwarden_master_password", - "vaultwarden_browser_extension", - "vaultwarden_mobile_app", - "keycloak_password_rotated", - "element_recovery_key", - "mail_client_setup", - "nextcloud_web_access", - "nextcloud_mail_integration", - "budget_encryption_ack", - "firefly_password_rotated", - "wger_password_rotated", - "jellyfin_web_access", -) - -KEYCLOAK_MANAGED_STEPS: set[str] = { - "keycloak_password_rotated", - "nextcloud_mail_integration", -} -_KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT = "keycloak_password_rotation_requested_at" - -ONBOARDING_STEP_PREREQUISITES: dict[str, set[str]] = { - "vaultwarden_master_password": set(), - "vaultwarden_store_temp_password": {"vaultwarden_master_password"}, - "vaultwarden_browser_extension": {"vaultwarden_master_password"}, - "vaultwarden_mobile_app": {"vaultwarden_master_password"}, - "keycloak_password_rotated": {"vaultwarden_master_password"}, - "element_recovery_key": {"keycloak_password_rotated"}, - "element_mobile_app": {"element_recovery_key"}, - "mail_client_setup": {"vaultwarden_master_password"}, - "nextcloud_web_access": {"vaultwarden_master_password"}, - "nextcloud_mail_integration": {"nextcloud_web_access"}, - "nextcloud_desktop_app": {"nextcloud_web_access"}, - "nextcloud_mobile_app": {"nextcloud_web_access"}, - "budget_encryption_ack": {"nextcloud_mail_integration"}, - "firefly_password_rotated": {"element_recovery_key"}, - "wger_password_rotated": {"firefly_password_rotated"}, - "wger_mobile_app": {"wger_password_rotated"}, - "jellyfin_web_access": {"vaultwarden_master_password"}, - "jellyfin_mobile_app": {"jellyfin_web_access"}, - "jellyfin_tv_setup": {"jellyfin_web_access"}, -} - -VAULTWARDEN_GRANDFATHERED_FLAG = "vaultwarden_grandfathered" -_VAULTWARDEN_READY_STATUSES = {"already_present", "active", "ready", "grandfathered"} - - -def _normalize_status(status: str) -> str: - cleaned = (status or "").strip().lower() - if cleaned == "approved": - return "accounts_building" - return cleaned or "unknown" - - -def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]: - rows = conn.execute( - "SELECT step FROM access_request_onboarding_steps WHERE request_code = %s", - (request_code,), - ).fetchall() - completed: set[str] = set() - for row in rows: - step = row.get("step") if isinstance(row, dict) else None - if isinstance(step, str) and step: - completed.add(step) - return completed - - -def _normalize_flag_list(raw: Any) -> set[str]: - if isinstance(raw, list): - return {item for item in raw if isinstance(item, str) and item} - if isinstance(raw, str) and raw: - return {raw} - return set() - - -def _fetch_request_flags_and_email(conn, request_code: str) -> tuple[set[str], str]: - row = conn.execute( - "SELECT approval_flags, contact_email FROM access_requests WHERE request_code = %s", - (request_code,), - ).fetchone() - if not row: - return set(), "" - flags = _normalize_flag_list(row.get("approval_flags")) - email = row.get("contact_email") if isinstance(row, dict) else "" - return flags, (email or "").strip() - - -def _user_in_group(username: str, group_name: str) -> bool: - if not username or not group_name: - return False - if not admin_client().ready(): - return False - try: - user = admin_client().find_user(username) or {} - user_id = user.get("id") if isinstance(user, dict) else None - if not isinstance(user_id, str) or not user_id: - return False - groups = admin_client().list_user_groups(user_id) - except Exception: - return False - return group_name in groups - - -def _vaultwarden_grandfathered(conn, request_code: str, username: str) -> tuple[bool, str]: - flags, contact_email = _fetch_request_flags_and_email(conn, request_code) - if VAULTWARDEN_GRANDFATHERED_FLAG in flags: - return True, contact_email - if _user_in_group(username, VAULTWARDEN_GRANDFATHERED_FLAG): - return True, contact_email - return False, contact_email - - -def _resolve_recovery_email(username: str, fallback: str) -> str: - if username and admin_client().ready(): - try: - user = admin_client().find_user(username) or {} - user_id = user.get("id") if isinstance(user, dict) else None - if isinstance(user_id, str) and user_id: - full = admin_client().get_user(user_id) - email = full.get("email") - if isinstance(email, str) and email.strip(): - return email.strip() - except Exception: - pass - return (fallback or "").strip() - - -def _password_rotation_requested(conn, request_code: str) -> bool: - row = conn.execute( - """ - SELECT 1 - FROM access_request_onboarding_artifacts - WHERE request_code = %s AND artifact = %s - LIMIT 1 - """, - (request_code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT), - ).fetchone() - return bool(row) - - -def _request_keycloak_password_rotation(conn, request_code: str, username: str) -> None: - if not username: - raise ValueError("username missing") - if not admin_client().ready(): - raise RuntimeError("keycloak admin unavailable") - - user = admin_client().find_user(username) or {} - user_id = user.get("id") if isinstance(user, dict) else None - if not isinstance(user_id, str) or not user_id: - raise RuntimeError("keycloak user not found") - - full = admin_client().get_user(user_id) - actions = full.get("requiredActions") - actions_list: list[str] = [] - if isinstance(actions, list): - actions_list = [a for a in actions if isinstance(a, str)] - if "UPDATE_PASSWORD" not in actions_list: - actions_list.append("UPDATE_PASSWORD") - admin_client().update_user_safe(user_id, {"requiredActions": actions_list}) - - conn.execute( - """ - INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash) - VALUES (%s, %s, NOW()::text) - ON CONFLICT (request_code, artifact) DO NOTHING - """, - (request_code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT), - ) - - -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 _vaultwarden_status_for_user(username: str) -> str: - if not username: - return "" - if not admin_client().ready(): - return "" - try: - user = admin_client().find_user(username) or {} - user_id = user.get("id") if isinstance(user, dict) else None - if not isinstance(user_id, str) or not user_id: - return "" - full = admin_client().get_user(user_id) - attrs = full.get("attributes") if isinstance(full, dict) else {} - return _extract_attr(attrs, "vaultwarden_status") - except Exception: - return "" - - -def _auto_completed_service_steps(attrs: Any) -> set[str]: - completed: set[str] = set() - if not isinstance(attrs, dict): - return completed - - vaultwarden_status = _extract_attr(attrs, "vaultwarden_status") - vaultwarden_master = _extract_attr(attrs, "vaultwarden_master_password_set_at") - if vaultwarden_master or vaultwarden_status in _VAULTWARDEN_READY_STATUSES: - completed.add("vaultwarden_master_password") - - nextcloud_synced_at = _extract_attr(attrs, "nextcloud_mail_synced_at") - if nextcloud_synced_at: - completed.add("nextcloud_mail_integration") - - firefly_rotated_at = _extract_attr(attrs, "firefly_password_rotated_at") - if firefly_rotated_at: - completed.add("firefly_password_rotated") - - wger_rotated_at = _extract_attr(attrs, "wger_password_rotated_at") - if wger_rotated_at: - completed.add("wger_password_rotated") - - return completed - - -def _auto_completed_keycloak_steps(conn, request_code: str, username: str) -> set[str]: - if not username: - return set() - if not admin_client().ready(): - return set() - if not request_code: - return set() - - completed: set[str] = set() - try: - user = admin_client().find_user(username) or {} - user_id = user.get("id") if isinstance(user, dict) else None - if not isinstance(user_id, str) or not user_id: - return set() - - full = {} - try: - full = admin_client().get_user(user_id) - except Exception: - full = user if isinstance(user, dict) else {} - - attrs = full.get("attributes") if isinstance(full, dict) else {} - completed |= _auto_completed_service_steps(attrs) - - actions = full.get("requiredActions") - required_actions: set[str] = set() - actions_list: list[str] = [] - if isinstance(actions, list): - actions_list = [a for a in actions if isinstance(a, str)] - required_actions = set(actions_list) - - if _password_rotation_requested(conn, request_code) and "UPDATE_PASSWORD" not in required_actions: - completed.add("keycloak_password_rotated") - - # Backfill: earlier accounts were created with CONFIGURE_TOTP as a required action, - # which forces users to enroll MFA at first login. We no longer require that, so - # remove it if present. - if "CONFIGURE_TOTP" in required_actions: - try: - admin_client().update_user_safe( - user_id, - {"requiredActions": [a for a in actions_list if a != "CONFIGURE_TOTP"]}, - ) - except Exception: - pass - except Exception: - return set() - - return completed - - -def _completed_onboarding_steps(conn, request_code: str, username: str) -> set[str]: - completed = _fetch_completed_onboarding_steps(conn, request_code) - return completed | _auto_completed_keycloak_steps(conn, request_code, username) - - -def _automation_ready(conn, request_code: str, username: str) -> bool: - if not username: - return False - if not admin_client().ready(): - return False - - # Prefer task-based readiness when we have task rows for the request. - task_row = conn.execute( - "SELECT 1 FROM access_request_tasks WHERE request_code = %s LIMIT 1", - (request_code,), - ).fetchone() - if task_row: - return provision_tasks_complete(conn, request_code) - - # Fallback for legacy requests: confirm user exists and has a mail app password. - try: - user = admin_client().find_user(username) - if not user: - return False - user_id = user.get("id") if isinstance(user, dict) else None - if not user_id: - return False - full = admin_client().get_user(str(user_id)) - attrs = full.get("attributes") or {} - if not isinstance(attrs, dict): - return False - raw_pw = attrs.get("mailu_app_password") - if isinstance(raw_pw, list): - return bool(raw_pw and isinstance(raw_pw[0], str) and raw_pw[0]) - return bool(isinstance(raw_pw, str) and raw_pw) - except Exception: - return False - - -def _advance_status(conn, request_code: str, username: str, status: str) -> str: - status = _normalize_status(status) - - if status == "accounts_building" and _automation_ready(conn, request_code, username): - conn.execute( - "UPDATE access_requests SET status = 'awaiting_onboarding' WHERE request_code = %s AND status = 'accounts_building'", - (request_code,), - ) - return "awaiting_onboarding" - - if status == "awaiting_onboarding": - completed = _completed_onboarding_steps(conn, request_code, username) - required_steps = set(ONBOARDING_REQUIRED_STEPS) - grandfathered, _ = _vaultwarden_grandfathered(conn, request_code, username) - vaultwarden_status = _vaultwarden_status_for_user(username) - if grandfathered and vaultwarden_status == "grandfathered": - required_steps.add("vaultwarden_store_temp_password") - if required_steps.issubset(completed): - conn.execute( - "UPDATE access_requests SET status = 'ready' WHERE request_code = %s AND status = 'awaiting_onboarding'", - (request_code,), - ) - return "ready" - - return status - - -def _onboarding_payload(conn, request_code: str, username: str) -> dict[str, Any]: - completed_steps = sorted(_completed_onboarding_steps(conn, request_code, username)) - password_rotation_requested = _password_rotation_requested(conn, request_code) - grandfathered, contact_email = _vaultwarden_grandfathered(conn, request_code, username) - recovery_email = _resolve_recovery_email(username, contact_email) if grandfathered else "" - vaultwarden_status = _vaultwarden_status_for_user(username) - vaultwarden_matched = grandfathered and vaultwarden_status == "grandfathered" - required_steps = list(ONBOARDING_REQUIRED_STEPS) - if vaultwarden_matched: - required_steps.append("vaultwarden_store_temp_password") - return { - "required_steps": required_steps, - "optional_steps": sorted(ONBOARDING_OPTIONAL_STEPS), - "completed_steps": completed_steps, - "keycloak": { - "password_rotation_requested": password_rotation_requested, - }, - "vaultwarden": { - "grandfathered": grandfathered, - "recovery_email": recovery_email, - "matched": vaultwarden_matched, - }, - } +from .access_request_onboarding import register_access_request_onboarding +from .access_request_state import * # noqa: F403 - facade preserves existing patch surface +from .access_request_status import register_access_request_status +from .access_request_submission import register_access_request_submission def register(app) -> None: - @app.route("/api/access/request/availability", methods=["GET"]) - def request_access_availability() -> Any: - if not settings.ACCESS_REQUEST_ENABLED: - return jsonify({"error": "request access disabled"}), 503 - if not configured(): - return jsonify({"error": "server not configured"}), 503 + """Register public access-request and onboarding routes.""" - username = (request.args.get("username") or "").strip() - error = _validate_username(username) - if error: - return jsonify({"available": False, "reason": "invalid", "detail": error}) - - if admin_client().ready() and admin_client().find_user(username): - return jsonify({"available": False, "reason": "exists", "detail": "username already exists"}) - - try: - with connect() as conn: - existing = conn.execute( - """ - SELECT status - FROM access_requests - WHERE username = %s - ORDER BY created_at DESC - LIMIT 1 - """, - (username,), - ).fetchone() - except Exception: - return jsonify({"error": "failed to check availability"}), 502 - - if existing: - status = str(existing.get("status") or "") - return jsonify( - { - "available": False, - "reason": "requested", - "status": _normalize_status(status), - } - ) - - return jsonify({"available": True}) - - @app.route("/api/access/request", methods=["POST"]) - def request_access() -> Any: - if not settings.ACCESS_REQUEST_ENABLED: - return jsonify({"error": "request access disabled"}), 503 - if not configured(): - return jsonify({"error": "server not configured"}), 503 - - ip = _client_ip() - username, email, note, first_name, last_name = _extract_request_payload() - first_name = _normalize_name(first_name) - last_name = _normalize_name(last_name) - - rate_key = ip - if username: - rate_key = f"{ip}:{username}" - if not rate_limit_allow( - rate_key, - key="access_request_submit", - limit=settings.ACCESS_REQUEST_SUBMIT_RATE_LIMIT, - window_sec=settings.ACCESS_REQUEST_SUBMIT_RATE_WINDOW_SEC, - ): - return jsonify({"error": "rate limited"}), 429 - - username_error = _validate_username(username) - if username_error: - return jsonify({"error": username_error}), 400 - name_error = _validate_name(first_name, label="first name", required=False) - if name_error: - return jsonify({"error": name_error}), 400 - name_error = _validate_name(last_name, label="last name", required=True) - if name_error: - return jsonify({"error": name_error}), 400 - if not email: - return jsonify({"error": "email is required"}), 400 - if "@" not in email: - return jsonify({"error": "invalid email"}), 400 - email_lower = email.lower() - if email_lower.endswith(f"@{settings.MAILU_DOMAIN.lower()}") and ( - email_lower not in settings.ACCESS_REQUEST_INTERNAL_EMAIL_ALLOWLIST - ): - return jsonify({"error": "email must be an external address"}), 400 - - if admin_client().ready() and admin_client().find_user(username): - return jsonify({"error": "username already exists"}), 409 - if admin_client().ready() and admin_client().find_user_by_email(email): - return jsonify({"error": "email is already associated with an existing Atlas account"}), 409 - - try: - with connect() as conn: - existing = conn.execute( - """ - SELECT request_code, status - FROM access_requests - WHERE username = %s AND status IN (%s, 'pending') - ORDER BY created_at DESC - LIMIT 1 - """, - (username, EMAIL_VERIFY_PENDING_STATUS), - ).fetchone() - if existing: - existing_status = str(existing.get("status") or "") - request_code = str(existing.get("request_code") or "") - if existing_status != EMAIL_VERIFY_PENDING_STATUS: - return jsonify({"ok": True, "request_code": request_code, "status": existing_status}) - - token = secrets.token_urlsafe(24) - token_hash = _hash_verification_token(token) - conn.execute( - """ - UPDATE access_requests - SET contact_email = %s, - note = %s, - first_name = %s, - last_name = %s, - email_verification_token_hash = %s, - email_verification_sent_at = NOW(), - email_verified_at = NULL - WHERE request_code = %s AND status = %s - """, - ( - email, - note or None, - first_name or None, - last_name or None, - token_hash, - request_code, - EMAIL_VERIFY_PENDING_STATUS, - ), - ) - - try: - _send_verification_email(request_code=request_code, email=email, token=token) - except MailerError: - return ( - jsonify({"error": "failed to send verification email", "request_code": request_code}), - 502, - ) - return jsonify({"ok": True, "request_code": request_code, "status": EMAIL_VERIFY_PENDING_STATUS}) - - request_code = _random_request_code(username) - token = secrets.token_urlsafe(24) - token_hash = _hash_verification_token(token) - try: - conn.execute( - """ - INSERT INTO access_requests - (request_code, username, contact_email, note, first_name, last_name, status, - email_verification_token_hash, email_verification_sent_at) - VALUES - (%s, %s, %s, %s, %s, %s, %s, %s, NOW()) - """, - ( - request_code, - username, - email, - note or None, - first_name or None, - last_name or None, - EMAIL_VERIFY_PENDING_STATUS, - token_hash, - ), - ) - except psycopg.errors.UniqueViolation: - conn.rollback() - existing = conn.execute( - """ - SELECT request_code, status - FROM access_requests - WHERE username = %s AND status IN (%s, 'pending') - ORDER BY created_at DESC - LIMIT 1 - """, - (username, EMAIL_VERIFY_PENDING_STATUS), - ).fetchone() - if not existing: - raise - return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]}) - - try: - _send_verification_email(request_code=request_code, email=email, token=token) - except MailerError: - return jsonify({"error": "failed to send verification email", "request_code": request_code}), 502 - except Exception: - return jsonify({"error": "failed to submit request"}), 502 - - return jsonify({"ok": True, "request_code": request_code, "status": EMAIL_VERIFY_PENDING_STATUS}) - - @app.route("/api/access/request/verify", methods=["POST"]) - def request_access_verify() -> Any: - if not settings.ACCESS_REQUEST_ENABLED: - return jsonify({"error": "request access disabled"}), 503 - if not configured(): - return jsonify({"error": "server not configured"}), 503 - - ip = _client_ip() - if not rate_limit_allow( - ip, - key="access_request_verify", - limit=60, - window_sec=60, - ): - return jsonify({"error": "rate limited"}), 429 - - payload = request.get_json(silent=True) or {} - code = (payload.get("request_code") or payload.get("code") or "").strip() - reveal_initial_password = bool( - payload.get("reveal_initial_password") or payload.get("reveal_password") - ) - token = (payload.get("token") or payload.get("verify") or "").strip() - if not code: - return jsonify({"error": "request_code is required"}), 400 - if not token: - return jsonify({"error": "token is required"}), 400 - - if not rate_limit_allow( - f"{ip}:{code}", - key="access_request_verify_code", - limit=30, - window_sec=60, - ): - return jsonify({"error": "rate limited"}), 429 - - try: - with connect() as conn: - status = _verify_request(conn, code, token) - return jsonify({"ok": True, "status": status}) - except VerificationError as exc: - return jsonify({"error": exc.message}), exc.status_code - except Exception: - return jsonify({"error": "failed to verify"}), 502 - - @app.route("/api/access/request/verify-link", methods=["GET"]) - def request_access_verify_link() -> Any: - if not settings.ACCESS_REQUEST_ENABLED: - return jsonify({"error": "request access disabled"}), 503 - if not configured(): - return jsonify({"error": "server not configured"}), 503 - - code = (request.args.get("code") or "").strip() - token = (request.args.get("token") or "").strip() - if not code or not token: - return redirect(f"/request-access?code={quote(code)}&verify_error=missing+token") - - try: - with connect() as conn: - _verify_request(conn, code, token) - return redirect(f"/request-access?code={quote(code)}&verified=1") - except VerificationError as exc: - return redirect(f"/request-access?code={quote(code)}&verify_error={quote(exc.message)}") - except Exception: - return redirect(f"/request-access?code={quote(code)}&verify_error=failed+to+verify") - - @app.route("/api/access/request/resend", methods=["POST"]) - def request_access_resend() -> Any: - if not settings.ACCESS_REQUEST_ENABLED: - return jsonify({"error": "request access disabled"}), 503 - if not configured(): - return jsonify({"error": "server not configured"}), 503 - - ip = _client_ip() - if not rate_limit_allow( - ip, - key="access_request_resend", - limit=30, - window_sec=60, - ): - return jsonify({"error": "rate limited"}), 429 - - payload = request.get_json(silent=True) or {} - code = (payload.get("request_code") or payload.get("code") or "").strip() - if not code: - return jsonify({"error": "request_code is required"}), 400 - - if not rate_limit_allow( - f"{ip}:{code}", - key="access_request_resend_code", - limit=10, - window_sec=300, - ): - return jsonify({"error": "rate limited"}), 429 - - try: - with connect() as conn: - row = conn.execute( - """ - SELECT status, contact_email - FROM access_requests - WHERE request_code = %s - """, - (code,), - ).fetchone() - if not row: - return jsonify({"error": "not found"}), 404 - - status = _normalize_status(row.get("status") or "") - if status != EMAIL_VERIFY_PENDING_STATUS: - return jsonify({"ok": True, "status": status}) - - email = str(row.get("contact_email") or "").strip() - if not email: - return jsonify({"error": "missing email"}), 409 - - token = secrets.token_urlsafe(24) - token_hash = _hash_verification_token(token) - conn.execute( - """ - UPDATE access_requests - SET email_verification_token_hash = %s, - email_verification_sent_at = NOW() - WHERE request_code = %s AND status = %s - """, - (token_hash, code, EMAIL_VERIFY_PENDING_STATUS), - ) - - try: - _send_verification_email(request_code=code, email=email, token=token) - except MailerError: - return jsonify({"error": "failed to send verification email", "request_code": code}), 502 - return jsonify({"ok": True, "status": EMAIL_VERIFY_PENDING_STATUS}) - except Exception: - return jsonify({"error": "failed to resend verification"}), 502 - - @app.route("/api/access/request/status", methods=["POST"]) - def request_access_status() -> Any: - if not settings.ACCESS_REQUEST_ENABLED: - return jsonify({"error": "request access disabled"}), 503 - if not configured(): - return jsonify({"error": "server not configured"}), 503 - - ip = _client_ip() - if not rate_limit_allow( - ip, - key="access_request_status", - limit=settings.ACCESS_REQUEST_STATUS_RATE_LIMIT, - window_sec=settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC, - ): - return jsonify({"error": "rate limited"}), 429 - - payload = request.get_json(silent=True) or {} - code = (payload.get("request_code") or payload.get("code") or "").strip() - reveal_initial_password = bool( - payload.get("reveal_initial_password") or payload.get("reveal_password") - ) - if not code: - return jsonify({"error": "request_code is required"}), 400 - - # Additional per-code limiter to avoid global NAT rate-limit blowups. - if not rate_limit_allow( - f"{ip}:{code}", - key="access_request_status_code", - limit=max(20, settings.ACCESS_REQUEST_STATUS_RATE_LIMIT), - window_sec=settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC, - ): - return jsonify({"error": "rate limited"}), 429 - - try: - with connect() as conn: - row = conn.execute( - """ - SELECT status, - username, - initial_password, - initial_password_revealed_at, - email_verified_at - FROM access_requests - WHERE request_code = %s - """, - (code,), - ).fetchone() - if not row: - return jsonify({"error": "not found"}), 404 - current_status = _normalize_status(row.get("status") or "") - if current_status == "accounts_building" and not ariadne_client.enabled(): - try: - provision_access_request(code) - except Exception: - pass - row = conn.execute( - """ - SELECT status, - username, - initial_password, - initial_password_revealed_at, - email_verified_at - FROM access_requests - WHERE request_code = %s - """, - (code,), - ).fetchone() - if not row: - return jsonify({"error": "not found"}), 404 - - status = _advance_status(conn, code, row.get("username") or "", row.get("status") or "") - response: dict[str, Any] = { - "ok": True, - "status": status, - "username": row.get("username") or "", - "email_verified": bool(row.get("email_verified_at")), - } - task_rows = conn.execute( - """ - SELECT task, status, detail, updated_at - FROM access_request_tasks - WHERE request_code = %s - ORDER BY task - """, - (code,), - ).fetchall() - if task_rows: - tasks: list[dict[str, Any]] = [] - blocked = False - for task_row in task_rows: - task_name = task_row.get("task") if isinstance(task_row, dict) else None - task_status = task_row.get("status") if isinstance(task_row, dict) else None - detail = task_row.get("detail") if isinstance(task_row, dict) else None - updated_at = task_row.get("updated_at") if isinstance(task_row, dict) else None - - if isinstance(task_status, str) and task_status == "error": - blocked = True - - task_payload: dict[str, Any] = { - "task": task_name or "", - "status": task_status or "", - } - if isinstance(detail, str) and detail: - task_payload["detail"] = detail - if isinstance(updated_at, datetime): - task_payload["updated_at"] = updated_at.astimezone(timezone.utc).isoformat() - tasks.append(task_payload) - - response["tasks"] = tasks - response["automation_complete"] = provision_tasks_complete(conn, code) - response["blocked"] = blocked - if status in {"awaiting_onboarding", "ready"}: - revealed_at = row.get("initial_password_revealed_at") - if isinstance(revealed_at, datetime): - response["initial_password_revealed_at"] = revealed_at.astimezone(timezone.utc).isoformat() - if reveal_initial_password: - password = row.get("initial_password") - if isinstance(password, str) and password and revealed_at is None: - response["initial_password"] = password - conn.execute( - "UPDATE access_requests SET initial_password_revealed_at = NOW() WHERE request_code = %s AND initial_password_revealed_at IS NULL", - (code,), - ) - if status in {"awaiting_onboarding", "ready"}: - response["onboarding_url"] = f"/onboarding?code={code}" - if status in {"awaiting_onboarding", "ready"}: - response["onboarding"] = _onboarding_payload(conn, code, row.get("username") or "") - return jsonify(response) - except Exception: - return jsonify({"error": "failed to load status"}), 502 - - @app.route("/api/access/request/retry", methods=["POST"]) - def request_access_retry() -> Any: - if not settings.ACCESS_REQUEST_ENABLED: - return jsonify({"error": "request access disabled"}), 503 - if not configured(): - return jsonify({"error": "server not configured"}), 503 - - ip = _client_ip() - if not rate_limit_allow( - ip, - key="access_request_retry", - limit=settings.ACCESS_REQUEST_STATUS_RATE_LIMIT, - window_sec=settings.ACCESS_REQUEST_STATUS_RATE_WINDOW_SEC, - ): - return jsonify({"error": "rate limited"}), 429 - - payload = request.get_json(silent=True) or {} - code = (payload.get("request_code") or payload.get("code") or "").strip() - tasks = payload.get("tasks") - task_list = [task for task in tasks if isinstance(task, str) and task.strip()] if isinstance(tasks, list) else [] - if not code: - return jsonify({"error": "request_code is required"}), 400 - - if ariadne_client.enabled(): - retry_payload = {"tasks": task_list} if task_list else None - return ariadne_client.proxy( - "POST", - f"/api/access/requests/{code}/retry", - payload=retry_payload, - ) - - try: - with connect() as conn: - row = conn.execute( - "SELECT status FROM access_requests WHERE request_code = %s", - (code,), - ).fetchone() - if not row: - return jsonify({"error": "not found"}), 404 - status = row.get("status") or "" - if status not in {"accounts_building", "approved"}: - return jsonify({"error": "request not retryable"}), 409 - conn.execute( - "UPDATE access_requests SET provision_attempted_at = NULL WHERE request_code = %s", - (code,), - ) - if task_list: - conn.execute( - """ - UPDATE access_request_tasks - SET status = 'pending', - detail = 'retry requested', - updated_at = NOW() - WHERE request_code = %s - AND task = ANY(%s::text[]) - AND status = 'error' - """, - (code, task_list), - ) - else: - conn.execute( - """ - UPDATE access_request_tasks - SET status = 'pending', - detail = 'retry requested', - updated_at = NOW() - WHERE request_code = %s AND status = 'error' - """, - (code,), - ) - except Exception: - return jsonify({"error": "failed to retry request"}), 502 - - try: - provision_access_request(code) - except Exception: - pass - return jsonify({"ok": True, "status": "accounts_building"}) - - @app.route("/api/access/request/onboarding/attest", methods=["POST"]) - def request_access_onboarding_attest() -> Any: - if not configured(): - return jsonify({"error": "server not configured"}), 503 - - payload = request.get_json(silent=True) or {} - code = (payload.get("request_code") or payload.get("code") or "").strip() - step = (payload.get("step") or "").strip() - completed = payload.get("completed") - vaultwarden_claim = bool(payload.get("vaultwarden_claim")) - - if not code: - return jsonify({"error": "request_code is required"}), 400 - if step not in ONBOARDING_STEPS: - return jsonify({"error": "invalid step"}), 400 - if step in KEYCLOAK_MANAGED_STEPS: - return jsonify({"error": "step is managed by keycloak"}), 400 - - username = "" - token_groups: set[str] = set() - bearer = request.headers.get("Authorization", "") - if bearer: - parts = bearer.split(None, 1) - if len(parts) != 2 or parts[0].lower() != "bearer": - return jsonify({"error": "invalid token"}), 401 - token = parts[1].strip() - if not token: - return jsonify({"error": "invalid token"}), 401 - try: - claims = oidc_client().verify(token) - except Exception: - return jsonify({"error": "invalid token"}), 401 - username = claims.get("preferred_username") or "" - groups = claims.get("groups") - if isinstance(groups, list): - token_groups = {g.lstrip("/") for g in groups if isinstance(g, str) and g} - - try: - with connect() as conn: - row = conn.execute( - "SELECT username, status, approval_flags, contact_email FROM access_requests WHERE request_code = %s", - (code,), - ).fetchone() - if not row: - return jsonify({"error": "not found"}), 404 - if username and (row.get("username") or "") != username: - return jsonify({"error": "forbidden"}), 403 - - status = _normalize_status(row.get("status") or "") - if status not in {"awaiting_onboarding", "ready"}: - return jsonify({"error": "onboarding not available"}), 409 - - mark_done = True - if isinstance(completed, bool): - mark_done = completed - - request_username = row.get("username") or "" - approval_flags = _normalize_flag_list(row.get("approval_flags")) - contact_email = (row.get("contact_email") or "").strip() - - if mark_done: - prerequisites = ONBOARDING_STEP_PREREQUISITES.get(step, set()) - if prerequisites: - current_completed = _completed_onboarding_steps(conn, code, request_username) - missing = sorted(prerequisites - current_completed) - if missing: - return jsonify({"error": "step is blocked", "blocked_by": missing}), 409 - if step in {"vaultwarden_master_password", "vaultwarden_store_temp_password"}: - if not _password_rotation_requested(conn, code): - try: - _request_keycloak_password_rotation(conn, code, request_username) - except Exception: - return jsonify({"error": "failed to request keycloak password rotation"}), 502 - - if step == "vaultwarden_master_password": - if vaultwarden_claim and not username: - return jsonify({"error": "login required"}), 401 - grandfathered = ( - VAULTWARDEN_GRANDFATHERED_FLAG in approval_flags - or VAULTWARDEN_GRANDFATHERED_FLAG in token_groups - or _user_in_group(request_username, VAULTWARDEN_GRANDFATHERED_FLAG) - ) - if vaultwarden_claim and not grandfathered: - return jsonify({"error": "vaultwarden claim not allowed"}), 403 - if vaultwarden_claim and not admin_client().ready(): - return jsonify({"error": "keycloak admin unavailable"}), 503 - if request_username and admin_client().ready(): - try: - now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") - if vaultwarden_claim: - recovery_email = _resolve_recovery_email(request_username, contact_email) - if not recovery_email: - return jsonify({"error": "recovery email missing"}), 409 - admin_client().set_user_attribute( - request_username, - "vaultwarden_email", - recovery_email, - ) - admin_client().set_user_attribute( - request_username, - "vaultwarden_status", - "grandfathered", - ) - admin_client().set_user_attribute( - request_username, - "vaultwarden_synced_at", - now, - ) - else: - admin_client().set_user_attribute( - request_username, - "vaultwarden_status", - "already_present", - ) - admin_client().set_user_attribute( - request_username, - "vaultwarden_master_password_set_at", - now, - ) - except Exception: - return jsonify({"error": "failed to update vaultwarden status"}), 502 - - conn.execute( - """ - INSERT INTO access_request_onboarding_steps (request_code, step) - VALUES (%s, %s) - ON CONFLICT (request_code, step) DO NOTHING - """, - (code, step), - ) - else: - conn.execute( - "DELETE FROM access_request_onboarding_steps WHERE request_code = %s AND step = %s", - (code, step), - ) - - # Re-evaluate completion to update request status to ready if applicable. - status = _advance_status(conn, code, request_username, status) - onboarding_payload = _onboarding_payload(conn, code, request_username) - except Exception: - return jsonify({"error": "failed to update onboarding"}), 502 - - return jsonify( - { - "ok": True, - "status": status, - "onboarding": onboarding_payload, - } - ) - - @app.route("/api/access/request/onboarding/keycloak-password-rotate", methods=["POST"]) - def request_access_onboarding_keycloak_password_rotate() -> Any: - if not configured(): - return jsonify({"error": "server not configured"}), 503 - - payload = request.get_json(silent=True) or {} - code = (payload.get("request_code") or payload.get("code") or "").strip() - if not code: - return jsonify({"error": "request_code is required"}), 400 - - token_username = "" - bearer = request.headers.get("Authorization", "") - if bearer: - parts = bearer.split(None, 1) - if len(parts) != 2 or parts[0].lower() != "bearer": - return jsonify({"error": "invalid token"}), 401 - token = parts[1].strip() - if not token: - return jsonify({"error": "invalid token"}), 401 - try: - claims = oidc_client().verify(token) - except Exception: - return jsonify({"error": "invalid token"}), 401 - token_username = claims.get("preferred_username") or "" - - if not admin_client().ready(): - return jsonify({"error": "keycloak admin unavailable"}), 503 - - try: - with connect() as conn: - row = conn.execute( - "SELECT username, status FROM access_requests WHERE request_code = %s", - (code,), - ).fetchone() - if not row: - return jsonify({"error": "not found"}), 404 - request_username = row.get("username") or "" - if token_username and request_username != token_username: - return jsonify({"error": "forbidden"}), 403 - - status = _normalize_status(row.get("status") or "") - if status not in {"awaiting_onboarding", "ready"}: - return jsonify({"error": "onboarding not available"}), 409 - - prerequisites = ONBOARDING_STEP_PREREQUISITES.get("keycloak_password_rotated", set()) - if prerequisites: - current_completed = _completed_onboarding_steps(conn, code, request_username) - missing = sorted(prerequisites - current_completed) - if missing: - return jsonify({"error": "step is blocked", "blocked_by": missing}), 409 - - user = admin_client().find_user(request_username) or {} - user_id = user.get("id") if isinstance(user, dict) else None - if not isinstance(user_id, str) or not user_id: - return jsonify({"error": "keycloak user not found"}), 409 - - full = admin_client().get_user(user_id) - actions = full.get("requiredActions") - actions_list: list[str] = [] - if isinstance(actions, list): - actions_list = [a for a in actions if isinstance(a, str)] - - rotation_requested = _password_rotation_requested(conn, code) - already_rotated = rotation_requested and "UPDATE_PASSWORD" not in actions_list - - if not already_rotated: - if "UPDATE_PASSWORD" not in actions_list: - actions_list.append("UPDATE_PASSWORD") - admin_client().update_user_safe(user_id, {"requiredActions": actions_list}) - if not rotation_requested: - conn.execute( - """ - INSERT INTO access_request_onboarding_artifacts (request_code, artifact, value_hash) - VALUES (%s, %s, NOW()::text) - ON CONFLICT (request_code, artifact) DO NOTHING - """, - (code, _KEYCLOAK_PASSWORD_ROTATION_REQUESTED_ARTIFACT), - ) - - onboarding_payload = _onboarding_payload(conn, code, request_username) - except Exception: - return jsonify({"error": "failed to request password rotation"}), 502 - - return jsonify({"ok": True, "status": status, "onboarding": onboarding_payload}) + deps = sys.modules[__name__] + register_access_request_submission(app, deps) + register_access_request_status(app, deps) + register_access_request_onboarding(app, deps)