diff --git a/backend/atlas_portal/ariadne_client.py b/backend/atlas_portal/ariadne_client.py new file mode 100644 index 0000000..95b68d1 --- /dev/null +++ b/backend/atlas_portal/ariadne_client.py @@ -0,0 +1,73 @@ +from __future__ import annotations + +from typing import Any + +import httpx +from flask import jsonify, request + +from . import settings + + +class AriadneError(Exception): + def __init__(self, message: str, status_code: int = 502) -> None: + super().__init__(message) + self.status_code = status_code + + +def enabled() -> bool: + return bool(settings.ARIADNE_URL) + + +def _auth_headers() -> dict[str, str]: + header = request.headers.get("Authorization", "").strip() + return {"Authorization": header} if header else {} + + +def _url(path: str) -> str: + base = settings.ARIADNE_URL.rstrip("/") + suffix = path.lstrip("/") + return f"{base}/{suffix}" if suffix else base + + +def request_raw( + method: str, + path: str, + *, + payload: Any | None = None, + params: dict[str, Any] | None = None, +) -> httpx.Response: + if not enabled(): + raise AriadneError("ariadne not configured", 503) + + try: + with httpx.Client(timeout=settings.ARIADNE_TIMEOUT_SEC) as client: + return client.request( + method, + _url(path), + headers=_auth_headers(), + json=payload, + params=params, + ) + except httpx.RequestError as exc: + raise AriadneError("ariadne unavailable", 502) from exc + + +def proxy( + method: str, + path: str, + *, + payload: Any | None = None, + params: dict[str, Any] | None = None, +) -> tuple[Any, int]: + try: + resp = request_raw(method, path, payload=payload, params=params) + except AriadneError as exc: + return jsonify({"error": str(exc)}), exc.status_code + + try: + data = resp.json() + except ValueError: + detail = resp.text.strip() + data = {"error": detail or "upstream error"} + + return jsonify(data), resp.status_code diff --git a/backend/atlas_portal/db.py b/backend/atlas_portal/db.py index 6bca00b..944bc6b 100644 --- a/backend/atlas_portal/db.py +++ b/backend/atlas_portal/db.py @@ -51,6 +51,10 @@ def ensure_schema() -> None: conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verification_token_hash TEXT") conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verification_sent_at TIMESTAMPTZ") conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS email_verified_at TIMESTAMPTZ") + conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS welcome_email_sent_at TIMESTAMPTZ") + conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS approval_flags TEXT[]") + conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS approval_note TEXT") + conn.execute("ALTER TABLE access_requests ADD COLUMN IF NOT EXISTS denial_note TEXT") conn.execute( """ CREATE TABLE IF NOT EXISTS access_request_tasks ( diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index 2557e24..1f2687c 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -13,6 +13,7 @@ from flask import jsonify, request, g import psycopg +from .. import ariadne_client from ..db import connect, configured from ..keycloak import admin_client, require_auth from ..mailer import MailerError, access_request_verification_body, send_text_email @@ -541,7 +542,7 @@ def register(app) -> None: if not row: return jsonify({"error": "not found"}), 404 current_status = _normalize_status(row.get("status") or "") - if current_status == "accounts_building": + if current_status == "accounts_building" and not ariadne_client.enabled(): try: provision_access_request(code) except Exception: diff --git a/backend/atlas_portal/routes/account.py b/backend/atlas_portal/routes/account.py index f60f9da..1e124b6 100644 --- a/backend/atlas_portal/routes/account.py +++ b/backend/atlas_portal/routes/account.py @@ -9,6 +9,7 @@ import httpx from flask import jsonify, g, request from .. import settings +from .. import ariadne_client from ..db import connect from ..keycloak import admin_client, require_auth, require_account_access from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync @@ -343,6 +344,8 @@ def register(app) -> None: ok, resp = require_account_access() if not ok: return resp + if ariadne_client.enabled(): + return ariadne_client.proxy("POST", "/api/account/mailu/rotate") if not admin_client().ready(): return jsonify({"error": "server not configured"}), 503 @@ -394,6 +397,8 @@ def register(app) -> None: ok, resp = require_account_access() if not ok: return resp + if ariadne_client.enabled(): + return ariadne_client.proxy("POST", "/api/account/wger/reset") if not admin_client().ready(): return jsonify({"error": "server not configured"}), 503 @@ -445,6 +450,8 @@ def register(app) -> None: ok, resp = require_account_access() if not ok: return resp + if ariadne_client.enabled(): + return ariadne_client.proxy("POST", "/api/account/firefly/reset") if not admin_client().ready(): return jsonify({"error": "server not configured"}), 503 @@ -496,6 +503,9 @@ def register(app) -> None: ok, resp = require_account_access() if not ok: return resp + if ariadne_client.enabled(): + payload = request.get_json(silent=True) or {} + return ariadne_client.proxy("POST", "/api/account/nextcloud/mail/sync", payload=payload) if not admin_client().ready(): return jsonify({"error": "server not configured"}), 503 diff --git a/backend/atlas_portal/routes/admin_access.py b/backend/atlas_portal/routes/admin_access.py index 9cb907c..f342de0 100644 --- a/backend/atlas_portal/routes/admin_access.py +++ b/backend/atlas_portal/routes/admin_access.py @@ -1,9 +1,11 @@ from __future__ import annotations from typing import Any +from urllib.parse import quote -from flask import jsonify, g +from flask import jsonify, g, request +from .. import ariadne_client from ..db import connect, configured from ..keycloak import require_auth, require_portal_admin from ..provisioning import provision_access_request @@ -18,6 +20,8 @@ def register(app) -> None: return resp if not configured(): return jsonify({"error": "server not configured"}), 503 + if ariadne_client.enabled(): + return ariadne_client.proxy("GET", "/api/admin/access/requests") try: with connect() as conn: @@ -55,18 +59,34 @@ def register(app) -> None: return resp if not configured(): return jsonify({"error": "server not configured"}), 503 + payload = request.get_json(silent=True) or {} + if ariadne_client.enabled(): + return ariadne_client.proxy( + "POST", + f"/api/admin/access/requests/{quote(username, safe='')}/approve", + payload=payload, + ) decided_by = getattr(g, "keycloak_username", "") or "" + flags_raw = payload.get("flags") if isinstance(payload, dict) else None + flags = [f for f in flags_raw if isinstance(f, str)] if isinstance(flags_raw, list) else [] + note = payload.get("note") if isinstance(payload, dict) else None + note = str(note).strip() if isinstance(note, str) else None try: with connect() as conn: row = conn.execute( """ UPDATE access_requests - SET status = 'accounts_building', decided_at = NOW(), decided_by = %s + SET status = 'accounts_building', + decided_at = NOW(), + decided_by = %s, + approval_flags = %s, + approval_note = %s WHERE username = %s AND status = 'pending' + AND email_verified_at IS NOT NULL RETURNING request_code """, - (decided_by or None, username), + (decided_by or None, flags or None, note, username), ).fetchone() except Exception: return jsonify({"error": "failed to approve request"}), 502 @@ -90,18 +110,30 @@ def register(app) -> None: return resp if not configured(): return jsonify({"error": "server not configured"}), 503 + payload = request.get_json(silent=True) or {} + if ariadne_client.enabled(): + return ariadne_client.proxy( + "POST", + f"/api/admin/access/requests/{quote(username, safe='')}/deny", + payload=payload, + ) decided_by = getattr(g, "keycloak_username", "") or "" + note = payload.get("note") if isinstance(payload, dict) else None + note = str(note).strip() if isinstance(note, str) else None try: with connect() as conn: row = conn.execute( """ UPDATE access_requests - SET status = 'denied', decided_at = NOW(), decided_by = %s + SET status = 'denied', + decided_at = NOW(), + decided_by = %s, + denial_note = %s WHERE username = %s AND status = 'pending' RETURNING request_code """, - (decided_by or None, username), + (decided_by or None, note, username), ).fetchone() except Exception: return jsonify({"error": "failed to deny request"}), 502 diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index 0aaffe8..c704558 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -48,6 +48,9 @@ KEYCLOAK_ADMIN_CLIENT_ID = os.getenv("KEYCLOAK_ADMIN_CLIENT_ID", "") KEYCLOAK_ADMIN_CLIENT_SECRET = os.getenv("KEYCLOAK_ADMIN_CLIENT_SECRET", "") KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM) +ARIADNE_URL = os.getenv("ARIADNE_URL", "").strip() +ARIADNE_TIMEOUT_SEC = float(os.getenv("ARIADNE_TIMEOUT_SEC", "10")) + ACCOUNT_ALLOWED_GROUPS = [ g.strip() for g in os.getenv("ACCOUNT_ALLOWED_GROUPS", "dev,admin").split(",")