From 456974b3c9dcf1b534c9a60585662a0b3b0f3fee Mon Sep 17 00:00:00 2001 From: Brad Stein Date: Fri, 2 Jan 2026 00:41:49 -0300 Subject: [PATCH] portal: store access requests in postgres --- backend/atlas_portal/app_factory.py | 4 +- backend/atlas_portal/db.py | 54 ++++++ .../atlas_portal/routes/access_requests.py | 106 ++++++------ backend/atlas_portal/routes/admin_access.py | 157 ++++++------------ backend/atlas_portal/settings.py | 3 +- backend/requirements.txt | 1 + 6 files changed, 171 insertions(+), 154 deletions(-) create mode 100644 backend/atlas_portal/db.py diff --git a/backend/atlas_portal/app_factory.py b/backend/atlas_portal/app_factory.py index d0f4d0e..038cb1d 100644 --- a/backend/atlas_portal/app_factory.py +++ b/backend/atlas_portal/app_factory.py @@ -7,6 +7,7 @@ from flask import Flask, jsonify, send_from_directory from flask_cors import CORS from werkzeug.middleware.proxy_fix import ProxyFix +from .db import ensure_schema from .routes import access_requests, account, admin_access, ai, auth_config, health, lab, monero @@ -15,6 +16,8 @@ def create_app() -> Flask: app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1) CORS(app, resources={r"/api/*": {"origins": "*"}}) + ensure_schema() + health.register(app) auth_config.register(app) account.register(app) @@ -44,4 +47,3 @@ def create_app() -> Flask: ) return app - diff --git a/backend/atlas_portal/db.py b/backend/atlas_portal/db.py new file mode 100644 index 0000000..7086ac2 --- /dev/null +++ b/backend/atlas_portal/db.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import Any, Iterator + +import psycopg +from psycopg.rows import dict_row + +from . import settings + + +def configured() -> bool: + return bool(settings.PORTAL_DATABASE_URL) + + +@contextmanager +def connect() -> Iterator[psycopg.Connection[Any]]: + if not settings.PORTAL_DATABASE_URL: + raise RuntimeError("portal database not configured") + with psycopg.connect(settings.PORTAL_DATABASE_URL, row_factory=dict_row) as conn: + yield conn + + +def ensure_schema() -> None: + if not settings.PORTAL_DATABASE_URL: + return + with connect() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS access_requests ( + request_code TEXT PRIMARY KEY, + username TEXT NOT NULL, + contact_email TEXT, + note TEXT, + status TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + decided_at TIMESTAMPTZ, + decided_by TEXT + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS access_requests_status_created_at + ON access_requests (status, created_at) + """ + ) + conn.execute( + """ + CREATE UNIQUE INDEX IF NOT EXISTS access_requests_username_pending + ON access_requests (username) + WHERE status = 'pending' + """ + ) diff --git a/backend/atlas_portal/routes/access_requests.py b/backend/atlas_portal/routes/access_requests.py index f57dbde..76272c7 100644 --- a/backend/atlas_portal/routes/access_requests.py +++ b/backend/atlas_portal/routes/access_requests.py @@ -3,11 +3,13 @@ from __future__ import annotations import re import secrets import string -import time from typing import Any from flask import jsonify, request +import psycopg + +from ..db import connect, configured from ..keycloak import admin_client from ..rate_limit import rate_limit_allow from .. import settings @@ -31,7 +33,7 @@ def register(app) -> None: def request_access() -> Any: if not settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 - if not admin_client().ready(): + if not configured(): return jsonify({"error": "server not configured"}), 503 ip = request.remote_addr or "unknown" @@ -49,35 +51,60 @@ def register(app) -> None: if email and "@" not in email: return jsonify({"error": "invalid email"}), 400 - if admin_client().find_user(username): + if admin_client().ready() and admin_client().find_user(username): return jsonify({"error": "username already exists"}), 409 - request_code = _random_request_code(username) - attrs: dict[str, Any] = { - "access_request_code": [request_code], - "access_request_status": ["pending"], - "access_request_note": [note] if note else [""], - "access_request_created_at": [str(int(time.time()))], - "access_request_contact_email": [email] if email else [""], - } - - user_payload: dict[str, Any] = {"username": username, "enabled": False, "attributes": attrs} - if email: - user_payload["email"] = email - user_payload["emailVerified"] = False - try: - user_id = admin_client().create_user(user_payload) + with connect() as conn: + existing = conn.execute( + """ + SELECT request_code, status + FROM access_requests + WHERE username = %s AND status = 'pending' + ORDER BY created_at DESC + LIMIT 1 + """, + (username,), + ).fetchone() + if existing: + return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]}) + + request_code = _random_request_code(username) + try: + conn.execute( + """ + INSERT INTO access_requests + (request_code, username, contact_email, note, status) + VALUES + (%s, %s, %s, %s, 'pending') + """, + (request_code, username, email or None, note or None), + ) + except psycopg.errors.UniqueViolation: + conn.rollback() + existing = conn.execute( + """ + SELECT request_code, status + FROM access_requests + WHERE username = %s AND status = 'pending' + ORDER BY created_at DESC + LIMIT 1 + """, + (username,), + ).fetchone() + if not existing: + raise + return jsonify({"ok": True, "request_code": existing["request_code"], "status": existing["status"]}) except Exception: return jsonify({"error": "failed to submit request"}), 502 - return jsonify({"ok": True, "id": user_id, "request_code": request_code}) + return jsonify({"ok": True, "request_code": request_code}) @app.route("/api/access/request/status", methods=["POST"]) def request_access_status() -> Any: if not settings.ACCESS_REQUEST_ENABLED: return jsonify({"error": "request access disabled"}), 503 - if not admin_client().ready(): + if not configured(): return jsonify({"error": "server not configured"}), 503 ip = request.remote_addr or "unknown" @@ -89,31 +116,14 @@ def register(app) -> None: if not code: return jsonify({"error": "request_code is required"}), 400 - username = (payload.get("username") or "").strip() - if not username: - if "~" not in code: - return jsonify({"error": "invalid request code"}), 400 - username = code.split("~", 1)[0].strip() - if not username: - return jsonify({"error": "invalid request code"}), 400 - - user = admin_client().find_user(username) - if not user: - return jsonify({"error": "not found"}), 404 - - attrs = user.get("attributes") or {} - if not isinstance(attrs, dict): - return jsonify({"error": "not found"}), 404 - - stored = attrs.get("access_request_code") or [""] - stored_code = stored[0] if isinstance(stored, list) and stored else (stored if isinstance(stored, str) else "") - if stored_code != code: - return jsonify({"error": "not found"}), 404 - - status = attrs.get("access_request_status") or [""] - status_value = ( - status[0] if isinstance(status, list) and status else (status if isinstance(status, str) else "") - ) - - return jsonify({"ok": True, "status": status_value or "unknown"}) - + try: + with connect() as conn: + row = conn.execute( + "SELECT status FROM access_requests WHERE request_code = %s", + (code,), + ).fetchone() + if not row: + return jsonify({"error": "not found"}), 404 + return jsonify({"ok": True, "status": row["status"] or "unknown"}) + except Exception: + return jsonify({"error": "failed to load status"}), 502 diff --git a/backend/atlas_portal/routes/admin_access.py b/backend/atlas_portal/routes/admin_access.py index 510f09a..5ab7571 100644 --- a/backend/atlas_portal/routes/admin_access.py +++ b/backend/atlas_portal/routes/admin_access.py @@ -1,49 +1,11 @@ from __future__ import annotations -import time from typing import Any -from flask import jsonify, request -import httpx +from flask import jsonify, g -from .. import settings -from ..keycloak import admin_client, require_auth, require_portal_admin - - -def _kc_admin_list_pending_requests(limit: int = 100) -> list[dict[str, Any]]: - def is_pending(user: dict[str, Any]) -> bool: - attrs = user.get("attributes") or {} - if not isinstance(attrs, dict): - return False - status = attrs.get("access_request_status") - if isinstance(status, list) and status: - return str(status[0]) == "pending" - if isinstance(status, str): - return status == "pending" - return False - - url = f"{settings.KEYCLOAK_ADMIN_URL}/admin/realms/{settings.KEYCLOAK_REALM}/users" - - candidates: list[dict[str, Any]] = [] - for params in ( - {"max": str(limit), "enabled": "false", "q": "access_request_status:pending"}, - {"max": str(limit), "enabled": "false"}, - {"max": str(limit)}, - ): - try: - with httpx.Client(timeout=settings.HTTP_CHECK_TIMEOUT_SEC) as client: - resp = client.get(url, params=params, headers=admin_client().headers()) - resp.raise_for_status() - users = resp.json() - if not isinstance(users, list): - continue - candidates = [u for u in users if isinstance(u, dict)] - break - except httpx.HTTPStatusError: - continue - - pending = [u for u in candidates if is_pending(u)] - return pending[:limit] +from ..db import connect, configured +from ..keycloak import require_auth, require_portal_admin def register(app) -> None: @@ -53,27 +15,33 @@ def register(app) -> None: ok, resp = require_portal_admin() if not ok: return resp - if not admin_client().ready(): + if not configured(): return jsonify({"error": "server not configured"}), 503 try: - items = _kc_admin_list_pending_requests() + with connect() as conn: + rows = conn.execute( + """ + SELECT request_code, username, contact_email, note, status, created_at + FROM access_requests + WHERE status = 'pending' + ORDER BY created_at ASC + LIMIT 200 + """ + ).fetchall() except Exception: return jsonify({"error": "failed to load requests"}), 502 output: list[dict[str, Any]] = [] - for user in items: - attrs = user.get("attributes") or {} - if not isinstance(attrs, dict): - attrs = {} + for row in rows: output.append( { - "id": user.get("id") or "", - "username": user.get("username") or "", - "email": user.get("email") or "", - "request_code": (attrs.get("access_request_code") or [""])[0], - "created_at": (attrs.get("access_request_created_at") or [""])[0], - "note": (attrs.get("access_request_note") or [""])[0], + "id": row["request_code"], + "username": row["username"], + "email": row.get("contact_email") or "", + "request_code": row["request_code"], + "created_at": (row.get("created_at").isoformat() if row.get("created_at") else ""), + "note": row.get("note") or "", } ) return jsonify({"requests": output}) @@ -84,43 +52,27 @@ def register(app) -> None: ok, resp = require_portal_admin() if not ok: return resp - if not admin_client().ready(): + if not configured(): return jsonify({"error": "server not configured"}), 503 - user = admin_client().find_user(username) - if not user: - return jsonify({"error": "user not found"}), 404 - user_id = user.get("id") or "" - if not user_id: - return jsonify({"error": "user id missing"}), 502 - - full = admin_client().get_user(user_id) - full["enabled"] = True - attrs = full.get("attributes") or {} - if not isinstance(attrs, dict): - attrs = {} - attrs["access_request_status"] = ["approved"] - attrs["access_request_approved_at"] = [str(int(time.time()))] - full["attributes"] = attrs + decided_by = getattr(g, "keycloak_username", "") or "" try: - admin_client().update_user(user_id, full) + with connect() as conn: + row = conn.execute( + """ + UPDATE access_requests + SET status = 'approved', decided_at = NOW(), decided_by = %s + WHERE username = %s AND status = 'pending' + RETURNING request_code + """, + (decided_by or None, username), + ).fetchone() except Exception: - return jsonify({"error": "failed to enable user"}), 502 + return jsonify({"error": "failed to approve request"}), 502 - group_id = admin_client().get_group_id("dev") - if group_id: - try: - admin_client().add_user_to_group(user_id, group_id) - except Exception: - pass - - if (full.get("email") or "").strip(): - try: - admin_client().execute_actions_email(user_id, ["UPDATE_PASSWORD"], request.host_url.rstrip("/") + "/") - except Exception: - pass - - return jsonify({"ok": True}) + if not row: + return jsonify({"ok": True, "request_code": ""}) + return jsonify({"ok": True, "request_code": row["request_code"]}) @app.route("/api/admin/access/requests//deny", methods=["POST"]) @require_auth @@ -128,27 +80,24 @@ def register(app) -> None: ok, resp = require_portal_admin() if not ok: return resp - if not admin_client().ready(): + if not configured(): return jsonify({"error": "server not configured"}), 503 - user = admin_client().find_user(username) - if not user: - return jsonify({"ok": True}) - user_id = user.get("id") or "" - if not user_id: - return jsonify({"error": "user id missing"}), 502 - - full = admin_client().get_user(user_id) - full["enabled"] = False - attrs = full.get("attributes") or {} - if not isinstance(attrs, dict): - attrs = {} - attrs["access_request_status"] = ["denied"] - attrs["access_request_denied_at"] = [str(int(time.time()))] - full["attributes"] = attrs + decided_by = getattr(g, "keycloak_username", "") or "" try: - admin_client().update_user(user_id, full) + with connect() as conn: + row = conn.execute( + """ + UPDATE access_requests + SET status = 'denied', decided_at = NOW(), decided_by = %s + WHERE username = %s AND status = 'pending' + RETURNING request_code + """, + (decided_by or None, username), + ).fetchone() except Exception: - return jsonify({"error": "failed to deny user"}), 502 + return jsonify({"error": "failed to deny request"}), 502 - return jsonify({"ok": True}) + if not row: + return jsonify({"ok": True, "request_code": ""}) + return jsonify({"ok": True, "request_code": row["request_code"]}) diff --git a/backend/atlas_portal/settings.py b/backend/atlas_portal/settings.py index 92ba685..7751773 100644 --- a/backend/atlas_portal/settings.py +++ b/backend/atlas_portal/settings.py @@ -53,6 +53,8 @@ ACCOUNT_ALLOWED_GROUPS = [ if g.strip() ] +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()] @@ -67,4 +69,3 @@ MAILU_SYNC_URL = os.getenv( ).rstrip("/") JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/") - diff --git a/backend/requirements.txt b/backend/requirements.txt index fde270a..ddb3bd0 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,3 +3,4 @@ flask-cors==4.0.0 gunicorn==21.2.0 httpx==0.27.2 PyJWT[crypto]==2.10.1 +psycopg[binary]==3.2.6