diff --git a/backend/atlas_portal/db.py b/backend/atlas_portal/db.py index 7086ac2..14a3676 100644 --- a/backend/atlas_portal/db.py +++ b/backend/atlas_portal/db.py @@ -39,12 +39,28 @@ def ensure_schema() -> None: ) """ ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS access_request_onboarding_steps ( + request_code TEXT NOT NULL REFERENCES access_requests(request_code) ON DELETE CASCADE, + step TEXT NOT NULL, + completed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (request_code, step) + ) + """ + ) conn.execute( """ CREATE INDEX IF NOT EXISTS access_requests_status_created_at ON access_requests (status, created_at) """ ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS access_request_onboarding_steps_request_code + ON access_request_onboarding_steps (request_code) + """ + ) conn.execute( """ CREATE UNIQUE INDEX IF NOT EXISTS access_requests_username_pending diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index b4a8f32..313f504 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -5,12 +5,12 @@ import secrets import string from typing import Any -from flask import jsonify, request +from flask import jsonify, request, g import psycopg from ..db import connect, configured -from ..keycloak import admin_client +from ..keycloak import admin_client, require_auth from ..rate_limit import rate_limit_allow from .. import settings @@ -38,6 +38,66 @@ def _client_ip() -> str: return request.remote_addr or "unknown" +ONBOARDING_STEPS: tuple[str, ...] = ( + "vaultwarden_master_password", + "element_recovery_key", + "element_recovery_key_stored", +) + + +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 _automation_ready(username: str) -> bool: + if not username: + return False + if not admin_client().ready(): + return False + try: + return bool(admin_client().find_user(username)) + 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(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 = _fetch_completed_onboarding_steps(conn, request_code) + if set(ONBOARDING_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 register(app) -> None: @app.route("/api/access/request", methods=["POST"]) def request_access() -> Any: @@ -157,14 +217,88 @@ def register(app) -> None: ).fetchone() if not row: return jsonify({"error": "not found"}), 404 - status = row["status"] or "unknown" + 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 "", } - if status == "approved": + if status in {"accounts_building", "awaiting_onboarding", "ready"}: response["onboarding_url"] = f"/onboarding?code={code}" + if status in {"awaiting_onboarding", "ready"}: + completed = sorted(_fetch_completed_onboarding_steps(conn, code)) + response["onboarding"] = { + "required_steps": list(ONBOARDING_STEPS), + "completed_steps": completed, + } return jsonify(response) except Exception: return jsonify({"error": "failed to load status"}), 502 + + @app.route("/api/access/request/onboarding/attest", methods=["POST"]) + @require_auth + 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") + + if not code: + return jsonify({"error": "request_code is required"}), 400 + if step not in ONBOARDING_STEPS: + return jsonify({"error": "invalid step"}), 400 + + username = getattr(g, "keycloak_username", "") or "" + if not username: + return jsonify({"error": "invalid token"}), 401 + + 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 + if (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 + + if mark_done: + 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, username, status) + completed_steps = sorted(_fetch_completed_onboarding_steps(conn, code)) + except Exception: + return jsonify({"error": "failed to update onboarding"}), 502 + + return jsonify( + { + "ok": True, + "status": status, + "onboarding": {"required_steps": list(ONBOARDING_STEPS), "completed_steps": completed_steps}, + } + ) diff --git a/backend/atlas_portal/routes/admin_access.py b/backend/atlas_portal/routes/admin_access.py index 5ab7571..5d95d7f 100644 --- a/backend/atlas_portal/routes/admin_access.py +++ b/backend/atlas_portal/routes/admin_access.py @@ -61,7 +61,7 @@ def register(app) -> None: row = conn.execute( """ UPDATE access_requests - SET status = 'approved', decided_at = NOW(), decided_by = %s + SET status = 'accounts_building', decided_at = NOW(), decided_by = %s WHERE username = %s AND status = 'pending' RETURNING request_code """, diff --git a/frontend/src/assets/theme.css b/frontend/src/assets/theme.css index 160b37e..af5980f 100644 --- a/frontend/src/assets/theme.css +++ b/frontend/src/assets/theme.css @@ -34,11 +34,26 @@ color: rgba(170, 255, 215, 0.92); } +.pill-info { + border-color: rgba(120, 180, 255, 0.42); + color: rgba(185, 225, 255, 0.92); +} + .pill-warn { border-color: rgba(255, 220, 120, 0.35); color: rgba(255, 230, 170, 0.92); } +.pill-wait { + border-color: rgba(255, 170, 80, 0.42); + color: rgba(255, 210, 170, 0.92); +} + +.pill-bad { + border-color: rgba(255, 96, 96, 0.45); + color: rgba(255, 170, 170, 0.92); +} + .card { background: var(--bg-panel); border: 1px solid var(--border); diff --git a/frontend/src/views/OnboardingView.vue b/frontend/src/views/OnboardingView.vue index 4d6dad2..70f41a0 100644 --- a/frontend/src/views/OnboardingView.vue +++ b/frontend/src/views/OnboardingView.vue @@ -11,8 +11,8 @@

Request Code

- - {{ status || "unknown" }} + + {{ statusLabel(status) }}
@@ -23,13 +23,88 @@ -
-

Next steps

-
    -
  1. Log in at cloud.bstein.dev.
  2. -
  3. Use your Keycloak username/password to access services.
  4. -
  5. If something doesn't work, contact the Atlas admin.
  6. -
+
+

Awaiting approval

+

An Atlas admin has to approve this request before an account can be provisioned.

+
+ +
+

Accounts building

+

+ Your request is approved. Atlas is now preparing accounts and credentials. Check back in a minute. +

+
+ +
+
+

Onboarding checklist

+ + {{ status === "ready" ? "ready" : "in progress" }} + +
+ +

+ Atlas can't reliably verify these steps automatically yet. Mark them complete once you're done. +

+ + + +
    +
  • + +

    + Open Passwords and set a strong master + password you won't forget. +

    +
  • + +
  • + +

    + In Element, create a recovery key so you can restore encrypted history if you lose a device. +

    +
  • + +
  • + +

    Save the recovery key in Vaultwarden so it doesn't get lost.

    +
  • +
+ +
+

You're ready

+

+ Your Atlas account is provisioned and onboarding is complete. You can log in at + cloud.bstein.dev. +

+
@@ -47,6 +122,7 @@