diff --git a/backend/atlas_portal/db.py b/backend/atlas_portal/db.py index 14a3676..b95661d 100644 --- a/backend/atlas_portal/db.py +++ b/backend/atlas_portal/db.py @@ -35,7 +35,23 @@ def ensure_schema() -> None: status TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), decided_at TIMESTAMPTZ, - decided_by TEXT + decided_by TEXT, + initial_password TEXT, + initial_password_revealed_at TIMESTAMPTZ + ) + """ + ) + conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password TEXT") + conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS initial_password_revealed_at TIMESTAMPTZ") + conn.execute( + """ + CREATE TABLE IF NOT EXISTS access_request_tasks ( + request_code TEXT NOT NULL REFERENCES access_requests(request_code) ON DELETE CASCADE, + task TEXT NOT NULL, + status TEXT NOT NULL, + detail TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (request_code, task) ) """ ) @@ -55,6 +71,12 @@ def ensure_schema() -> None: ON access_requests (status, created_at) """ ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS access_request_tasks_request_code + ON access_request_tasks (request_code) + """ + ) conn.execute( """ CREATE INDEX IF NOT EXISTS access_request_onboarding_steps_request_code diff --git a/backend/atlas_portal/keycloak.py b/backend/atlas_portal/keycloak.py index a68f27f..64f9b8b 100644 --- a/backend/atlas_portal/keycloak.py +++ b/backend/atlas_portal/keycloak.py @@ -130,6 +130,16 @@ class KeycloakAdminClient: return location.rstrip("/").split("/")[-1] raise RuntimeError("failed to determine created user id") + def reset_password(self, user_id: str, password: str, temporary: bool = True) -> None: + url = ( + f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}" + f"/users/{quote(user_id, safe='')}/reset-password" + ) + payload = {"type": "password", "value": password, "temporary": bool(temporary)} + 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.raise_for_status() + def set_user_attribute(self, username: str, key: str, value: str) -> None: user = self.find_user(username) if not user: diff --git a/backend/atlas_portal/provisioning.py b/backend/atlas_portal/provisioning.py new file mode 100644 index 0000000..493bad4 --- /dev/null +++ b/backend/atlas_portal/provisioning.py @@ -0,0 +1,210 @@ +from __future__ import annotations + +from dataclasses import dataclass +import time + +import httpx + +from . import settings +from .db import connect +from .keycloak import admin_client +from .utils import random_password + + +MAILU_APP_PASSWORD_ATTR = "mailu_app_password" +REQUIRED_PROVISION_TASKS: tuple[str, ...] = ( + "keycloak_user", + "keycloak_password", + "keycloak_groups", + "mailu_app_password", + "mailu_sync", +) + + +@dataclass(frozen=True) +class ProvisionResult: + ok: bool + status: str + + +def _upsert_task(conn, request_code: str, task: str, status: str, detail: str | None = None) -> None: + conn.execute( + """ + INSERT INTO access_request_tasks (request_code, task, status, detail, updated_at) + VALUES (%s, %s, %s, %s, NOW()) + ON CONFLICT (request_code, task) + DO UPDATE SET status = EXCLUDED.status, detail = EXCLUDED.detail, updated_at = NOW() + """, + (request_code, task, status, detail), + ) + + +def _task_statuses(conn, request_code: str) -> dict[str, str]: + rows = conn.execute( + "SELECT task, status FROM access_request_tasks WHERE request_code = %s", + (request_code,), + ).fetchall() + output: dict[str, str] = {} + for row in rows: + task = row.get("task") if isinstance(row, dict) else None + status = row.get("status") if isinstance(row, dict) else None + if isinstance(task, str) and isinstance(status, str): + output[task] = status + return output + + +def _all_tasks_ok(conn, request_code: str, tasks: list[str]) -> bool: + statuses = _task_statuses(conn, request_code) + for task in tasks: + if statuses.get(task) != "ok": + return False + return True + + +def provision_tasks_complete(conn, request_code: str) -> bool: + return _all_tasks_ok(conn, request_code, list(REQUIRED_PROVISION_TASKS)) + + +def provision_access_request(request_code: str) -> ProvisionResult: + if not request_code: + return ProvisionResult(ok=False, status="unknown") + if not admin_client().ready(): + return ProvisionResult(ok=False, status="accounts_building") + + required_tasks = list(REQUIRED_PROVISION_TASKS) + + with connect() as conn: + row = conn.execute( + """ + SELECT username, contact_email, status, initial_password, initial_password_revealed_at + FROM access_requests + WHERE request_code = %s + """, + (request_code,), + ).fetchone() + if not row: + return ProvisionResult(ok=False, status="unknown") + + username = str(row.get("username") or "") + contact_email = str(row.get("contact_email") or "") + status = str(row.get("status") or "") + initial_password = row.get("initial_password") + revealed_at = row.get("initial_password_revealed_at") + + if status not in {"accounts_building", "awaiting_onboarding", "ready"}: + return ProvisionResult(ok=False, status=status or "unknown") + + user_id = "" + + # Task: ensure Keycloak user exists + try: + user = admin_client().find_user(username) + if not user: + email = contact_email.strip() or f"{username}@{settings.MAILU_DOMAIN}" + payload = { + "username": username, + "enabled": True, + "email": email, + "emailVerified": False, + } + created_id = admin_client().create_user(payload) + user = admin_client().get_user(created_id) + user_id = str((user or {}).get("id") or "") + if not user_id: + raise RuntimeError("user id missing") + _upsert_task(conn, request_code, "keycloak_user", "ok", None) + except Exception: + _upsert_task(conn, request_code, "keycloak_user", "error", "failed to ensure user") + + # Task: set initial temporary password and store it for "show once" onboarding + try: + if user_id: + password_value = "" + if isinstance(initial_password, str) and initial_password: + password_value = initial_password + elif initial_password is None and revealed_at is None: + password_value = random_password(20) + conn.execute( + """ + UPDATE access_requests + SET initial_password = %s, initial_password_revealed_at = NULL + WHERE request_code = %s AND initial_password IS NULL + """, + (password_value, request_code), + ) + initial_password = password_value + elif isinstance(initial_password, str) and initial_password and revealed_at is None: + password_value = initial_password + + if password_value: + admin_client().reset_password(user_id, password_value, temporary=True) + _upsert_task(conn, request_code, "keycloak_password", "ok", None) + else: + raise RuntimeError("missing user id") + except Exception: + _upsert_task(conn, request_code, "keycloak_password", "error", "failed to set password") + + # Task: group membership (default dev) + try: + if user_id: + groups = settings.DEFAULT_USER_GROUPS or ["dev"] + for group_name in groups: + gid = admin_client().get_group_id(group_name) + if not gid: + raise RuntimeError("group missing") + admin_client().add_user_to_group(user_id, gid) + _upsert_task(conn, request_code, "keycloak_groups", "ok", None) + else: + raise RuntimeError("missing user id") + except Exception: + _upsert_task(conn, request_code, "keycloak_groups", "error", "failed to add groups") + + # Task: ensure mailu_app_password attribute exists + try: + if user_id: + full = admin_client().get_user(user_id) + attrs = full.get("attributes") or {} + existing = None + if isinstance(attrs, dict): + raw = attrs.get(MAILU_APP_PASSWORD_ATTR) + if isinstance(raw, list) and raw and isinstance(raw[0], str): + existing = raw[0] + elif isinstance(raw, str) and raw: + existing = raw + if not existing: + admin_client().set_user_attribute(username, MAILU_APP_PASSWORD_ATTR, random_password()) + _upsert_task(conn, request_code, "mailu_app_password", "ok", None) + else: + raise RuntimeError("missing user id") + except Exception: + _upsert_task(conn, request_code, "mailu_app_password", "error", "failed to set mail password") + + # Task: trigger Mailu sync if configured + try: + if not settings.MAILU_SYNC_URL: + _upsert_task(conn, request_code, "mailu_sync", "ok", "sync disabled") + else: + with httpx.Client(timeout=30) as client: + resp = client.post( + settings.MAILU_SYNC_URL, + json={"ts": int(time.time()), "wait": True, "reason": "portal_access_approve"}, + ) + if resp.status_code != 200: + raise RuntimeError("mailu sync failed") + _upsert_task(conn, request_code, "mailu_sync", "ok", None) + except Exception: + _upsert_task(conn, request_code, "mailu_sync", "error", "failed to sync mailu") + + # If everything is OK, advance to awaiting_onboarding. + if _all_tasks_ok(conn, request_code, required_tasks): + conn.execute( + """ + UPDATE access_requests + SET status = 'awaiting_onboarding' + WHERE request_code = %s AND status = 'accounts_building' + """, + (request_code,), + ) + return ProvisionResult(ok=True, status="awaiting_onboarding") + + return ProvisionResult(ok=False, status="accounts_building") diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 3c996ee..92ab734 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -12,6 +12,7 @@ import psycopg from ..db import connect, configured from ..keycloak import admin_client, require_auth from ..rate_limit import rate_limit_allow +from ..provisioning import provision_tasks_complete from .. import settings @@ -66,13 +67,36 @@ def _fetch_completed_onboarding_steps(conn, request_code: str) -> set[str]: return completed -def _automation_ready(username: str) -> bool: +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: - return bool(admin_client().find_user(username)) + 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 @@ -80,7 +104,7 @@ def _automation_ready(username: str) -> bool: def _advance_status(conn, request_code: str, username: str, status: str) -> str: status = _normalize_status(status) - if status == "accounts_building" and _automation_ready(username): + 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,), @@ -213,7 +237,7 @@ def register(app) -> None: try: with connect() as conn: row = conn.execute( - "SELECT status, username FROM access_requests WHERE request_code = %s", + "SELECT status, username, initial_password, initial_password_revealed_at FROM access_requests WHERE request_code = %s", (code,), ).fetchone() if not row: @@ -224,6 +248,15 @@ def register(app) -> None: "status": status, "username": row.get("username") or "", } + if status in {"awaiting_onboarding", "ready"}: + password = row.get("initial_password") + revealed_at = row.get("initial_password_revealed_at") + 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"}: diff --git a/backend/atlas_portal/routes/admin_access.py b/backend/atlas_portal/routes/admin_access.py index 5d95d7f..9cb907c 100644 --- a/backend/atlas_portal/routes/admin_access.py +++ b/backend/atlas_portal/routes/admin_access.py @@ -6,6 +6,7 @@ from flask import jsonify, g from ..db import connect, configured from ..keycloak import require_auth, require_portal_admin +from ..provisioning import provision_access_request def register(app) -> None: @@ -72,6 +73,13 @@ def register(app) -> None: if not row: return jsonify({"ok": True, "request_code": ""}) + + # Provision the account best-effort (Keycloak user + Mailu password + sync). + try: + provision_access_request(row["request_code"]) + except Exception: + # Keep the request in accounts_building; status checks will surface it. + pass return jsonify({"ok": True, "request_code": row["request_code"]}) @app.route("/api/admin/access/requests//deny", methods=["POST"]) diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index 394b56a..e6deb7c 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -58,6 +58,8 @@ PORTAL_DATABASE_URL = os.getenv("PORTAL_DATABASE_URL", "").strip() PORTAL_ADMIN_USERS = [u.strip() for u in os.getenv("PORTAL_ADMIN_USERS", "bstein").split(",") if u.strip()] PORTAL_ADMIN_GROUPS = [g.strip() for g in os.getenv("PORTAL_ADMIN_GROUPS", "admin").split(",") if g.strip()] +DEFAULT_USER_GROUPS = [g.strip() for g in os.getenv("DEFAULT_USER_GROUPS", "dev").split(",") if g.strip()] + ACCESS_REQUEST_ENABLED = _env_bool("ACCESS_REQUEST_ENABLED", "true") ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5")) ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60))) diff --git a/frontend/src/views/AppsView.vue b/frontend/src/views/AppsView.vue index c9f7cc0..1efb78a 100644 --- a/frontend/src/views/AppsView.vue +++ b/frontend/src/views/AppsView.vue @@ -10,119 +10,169 @@ -
-
-
-

{{ category.title }}

-

{{ category.description }}

+
+
+
+
+

{{ section.title }}

+

{{ section.description }}

+
-
- + +