feat: proxy portal actions to ariadne
This commit is contained in:
parent
e83a189d91
commit
deb3813c2e
73
backend/atlas_portal/ariadne_client.py
Normal file
73
backend/atlas_portal/ariadne_client.py
Normal 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
|
||||||
@ -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_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_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 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(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS access_request_tasks (
|
CREATE TABLE IF NOT EXISTS access_request_tasks (
|
||||||
|
|||||||
@ -13,6 +13,7 @@ from flask import jsonify, request, g
|
|||||||
|
|
||||||
import psycopg
|
import psycopg
|
||||||
|
|
||||||
|
from .. import ariadne_client
|
||||||
from ..db import connect, configured
|
from ..db import connect, configured
|
||||||
from ..keycloak import admin_client, require_auth
|
from ..keycloak import admin_client, require_auth
|
||||||
from ..mailer import MailerError, access_request_verification_body, send_text_email
|
from ..mailer import MailerError, access_request_verification_body, send_text_email
|
||||||
@ -541,7 +542,7 @@ def register(app) -> None:
|
|||||||
if not row:
|
if not row:
|
||||||
return jsonify({"error": "not found"}), 404
|
return jsonify({"error": "not found"}), 404
|
||||||
current_status = _normalize_status(row.get("status") or "")
|
current_status = _normalize_status(row.get("status") or "")
|
||||||
if current_status == "accounts_building":
|
if current_status == "accounts_building" and not ariadne_client.enabled():
|
||||||
try:
|
try:
|
||||||
provision_access_request(code)
|
provision_access_request(code)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import httpx
|
|||||||
from flask import jsonify, g, request
|
from flask import jsonify, g, request
|
||||||
|
|
||||||
from .. import settings
|
from .. import settings
|
||||||
|
from .. import ariadne_client
|
||||||
from ..db import connect
|
from ..db import connect
|
||||||
from ..keycloak import admin_client, require_auth, require_account_access
|
from ..keycloak import admin_client, require_auth, require_account_access
|
||||||
from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
|
from ..nextcloud_mail_sync import trigger as trigger_nextcloud_mail_sync
|
||||||
@ -343,6 +344,8 @@ def register(app) -> None:
|
|||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
|
if ariadne_client.enabled():
|
||||||
|
return ariadne_client.proxy("POST", "/api/account/mailu/rotate")
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
return jsonify({"error": "server not configured"}), 503
|
return jsonify({"error": "server not configured"}), 503
|
||||||
|
|
||||||
@ -394,6 +397,8 @@ def register(app) -> None:
|
|||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
|
if ariadne_client.enabled():
|
||||||
|
return ariadne_client.proxy("POST", "/api/account/wger/reset")
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
return jsonify({"error": "server not configured"}), 503
|
return jsonify({"error": "server not configured"}), 503
|
||||||
|
|
||||||
@ -445,6 +450,8 @@ def register(app) -> None:
|
|||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
return resp
|
||||||
|
if ariadne_client.enabled():
|
||||||
|
return ariadne_client.proxy("POST", "/api/account/firefly/reset")
|
||||||
if not admin_client().ready():
|
if not admin_client().ready():
|
||||||
return jsonify({"error": "server not configured"}), 503
|
return jsonify({"error": "server not configured"}), 503
|
||||||
|
|
||||||
@ -496,6 +503,9 @@ def register(app) -> None:
|
|||||||
ok, resp = require_account_access()
|
ok, resp = require_account_access()
|
||||||
if not ok:
|
if not ok:
|
||||||
return resp
|
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():
|
if not admin_client().ready():
|
||||||
return jsonify({"error": "server not configured"}), 503
|
return jsonify({"error": "server not configured"}), 503
|
||||||
|
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
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 ..db import connect, configured
|
||||||
from ..keycloak import require_auth, require_portal_admin
|
from ..keycloak import require_auth, require_portal_admin
|
||||||
from ..provisioning import provision_access_request
|
from ..provisioning import provision_access_request
|
||||||
@ -18,6 +20,8 @@ def register(app) -> None:
|
|||||||
return resp
|
return resp
|
||||||
if not configured():
|
if not configured():
|
||||||
return jsonify({"error": "server not configured"}), 503
|
return jsonify({"error": "server not configured"}), 503
|
||||||
|
if ariadne_client.enabled():
|
||||||
|
return ariadne_client.proxy("GET", "/api/admin/access/requests")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
@ -55,18 +59,34 @@ def register(app) -> None:
|
|||||||
return resp
|
return resp
|
||||||
if not configured():
|
if not configured():
|
||||||
return jsonify({"error": "server not configured"}), 503
|
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 ""
|
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:
|
try:
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE access_requests
|
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'
|
WHERE username = %s AND status = 'pending'
|
||||||
|
AND email_verified_at IS NOT NULL
|
||||||
RETURNING request_code
|
RETURNING request_code
|
||||||
""",
|
""",
|
||||||
(decided_by or None, username),
|
(decided_by or None, flags or None, note, username),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"error": "failed to approve request"}), 502
|
return jsonify({"error": "failed to approve request"}), 502
|
||||||
@ -90,18 +110,30 @@ def register(app) -> None:
|
|||||||
return resp
|
return resp
|
||||||
if not configured():
|
if not configured():
|
||||||
return jsonify({"error": "server not configured"}), 503
|
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 ""
|
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:
|
try:
|
||||||
with connect() as conn:
|
with connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"""
|
"""
|
||||||
UPDATE access_requests
|
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'
|
WHERE username = %s AND status = 'pending'
|
||||||
RETURNING request_code
|
RETURNING request_code
|
||||||
""",
|
""",
|
||||||
(decided_by or None, username),
|
(decided_by or None, note, username),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
except Exception:
|
except Exception:
|
||||||
return jsonify({"error": "failed to deny request"}), 502
|
return jsonify({"error": "failed to deny request"}), 502
|
||||||
|
|||||||
@ -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_CLIENT_SECRET = os.getenv("KEYCLOAK_ADMIN_CLIENT_SECRET", "")
|
||||||
KEYCLOAK_ADMIN_REALM = os.getenv("KEYCLOAK_ADMIN_REALM", KEYCLOAK_REALM)
|
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 = [
|
ACCOUNT_ALLOWED_GROUPS = [
|
||||||
g.strip()
|
g.strip()
|
||||||
for g in os.getenv("ACCOUNT_ALLOWED_GROUPS", "dev,admin").split(",")
|
for g in os.getenv("ACCOUNT_ALLOWED_GROUPS", "dev,admin").split(",")
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user