feat: proxy portal actions to ariadne

This commit is contained in:
Brad Stein 2026-01-19 19:21:22 -03:00
parent e83a189d91
commit deb3813c2e
6 changed files with 129 additions and 6 deletions

View File

@ -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

View File

@ -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 (

View File

@ -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:

View File

@ -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

View File

@ -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

View File

@ -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(",")