portal: store access requests in postgres

This commit is contained in:
Brad Stein 2026-01-02 00:41:49 -03:00
parent 575932b32f
commit 456974b3c9
6 changed files with 171 additions and 154 deletions

View File

@ -7,6 +7,7 @@ from flask import Flask, jsonify, send_from_directory
from flask_cors import CORS from flask_cors import CORS
from werkzeug.middleware.proxy_fix import ProxyFix 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 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) 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": "*"}}) CORS(app, resources={r"/api/*": {"origins": "*"}})
ensure_schema()
health.register(app) health.register(app)
auth_config.register(app) auth_config.register(app)
account.register(app) account.register(app)
@ -44,4 +47,3 @@ def create_app() -> Flask:
) )
return app return app

View File

@ -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'
"""
)

View File

@ -3,11 +3,13 @@ from __future__ import annotations
import re import re
import secrets import secrets
import string import string
import time
from typing import Any from typing import Any
from flask import jsonify, request from flask import jsonify, request
import psycopg
from ..db import connect, configured
from ..keycloak import admin_client from ..keycloak import admin_client
from ..rate_limit import rate_limit_allow from ..rate_limit import rate_limit_allow
from .. import settings from .. import settings
@ -31,7 +33,7 @@ def register(app) -> None:
def request_access() -> Any: def request_access() -> Any:
if not settings.ACCESS_REQUEST_ENABLED: if not settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503 return jsonify({"error": "request access disabled"}), 503
if not admin_client().ready(): if not configured():
return jsonify({"error": "server not configured"}), 503 return jsonify({"error": "server not configured"}), 503
ip = request.remote_addr or "unknown" ip = request.remote_addr or "unknown"
@ -49,35 +51,60 @@ def register(app) -> None:
if email and "@" not in email: if email and "@" not in email:
return jsonify({"error": "invalid email"}), 400 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 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: 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: except Exception:
return jsonify({"error": "failed to submit request"}), 502 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"]) @app.route("/api/access/request/status", methods=["POST"])
def request_access_status() -> Any: def request_access_status() -> Any:
if not settings.ACCESS_REQUEST_ENABLED: if not settings.ACCESS_REQUEST_ENABLED:
return jsonify({"error": "request access disabled"}), 503 return jsonify({"error": "request access disabled"}), 503
if not admin_client().ready(): if not configured():
return jsonify({"error": "server not configured"}), 503 return jsonify({"error": "server not configured"}), 503
ip = request.remote_addr or "unknown" ip = request.remote_addr or "unknown"
@ -89,31 +116,14 @@ def register(app) -> None:
if not code: if not code:
return jsonify({"error": "request_code is required"}), 400 return jsonify({"error": "request_code is required"}), 400
username = (payload.get("username") or "").strip() try:
if not username: with connect() as conn:
if "~" not in code: row = conn.execute(
return jsonify({"error": "invalid request code"}), 400 "SELECT status FROM access_requests WHERE request_code = %s",
username = code.split("~", 1)[0].strip() (code,),
if not username: ).fetchone()
return jsonify({"error": "invalid request code"}), 400 if not row:
user = admin_client().find_user(username)
if not user:
return jsonify({"error": "not found"}), 404 return jsonify({"error": "not found"}), 404
return jsonify({"ok": True, "status": row["status"] or "unknown"})
attrs = user.get("attributes") or {} except Exception:
if not isinstance(attrs, dict): return jsonify({"error": "failed to load status"}), 502
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"})

View File

@ -1,49 +1,11 @@
from __future__ import annotations from __future__ import annotations
import time
from typing import Any from typing import Any
from flask import jsonify, request from flask import jsonify, g
import httpx
from .. import settings from ..db import connect, configured
from ..keycloak import admin_client, require_auth, require_portal_admin from ..keycloak import 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]
def register(app) -> None: def register(app) -> None:
@ -53,27 +15,33 @@ def register(app) -> None:
ok, resp = require_portal_admin() ok, resp = require_portal_admin()
if not ok: if not ok:
return resp return resp
if not admin_client().ready(): if not configured():
return jsonify({"error": "server not configured"}), 503 return jsonify({"error": "server not configured"}), 503
try: 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: except Exception:
return jsonify({"error": "failed to load requests"}), 502 return jsonify({"error": "failed to load requests"}), 502
output: list[dict[str, Any]] = [] output: list[dict[str, Any]] = []
for user in items: for row in rows:
attrs = user.get("attributes") or {}
if not isinstance(attrs, dict):
attrs = {}
output.append( output.append(
{ {
"id": user.get("id") or "", "id": row["request_code"],
"username": user.get("username") or "", "username": row["username"],
"email": user.get("email") or "", "email": row.get("contact_email") or "",
"request_code": (attrs.get("access_request_code") or [""])[0], "request_code": row["request_code"],
"created_at": (attrs.get("access_request_created_at") or [""])[0], "created_at": (row.get("created_at").isoformat() if row.get("created_at") else ""),
"note": (attrs.get("access_request_note") or [""])[0], "note": row.get("note") or "",
} }
) )
return jsonify({"requests": output}) return jsonify({"requests": output})
@ -84,43 +52,27 @@ def register(app) -> None:
ok, resp = require_portal_admin() ok, resp = require_portal_admin()
if not ok: if not ok:
return resp return resp
if not admin_client().ready(): if not configured():
return jsonify({"error": "server not configured"}), 503 return jsonify({"error": "server not configured"}), 503
user = admin_client().find_user(username) decided_by = getattr(g, "keycloak_username", "") or ""
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
try: 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: 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 not row:
if group_id: return jsonify({"ok": True, "request_code": ""})
try: return jsonify({"ok": True, "request_code": row["request_code"]})
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})
@app.route("/api/admin/access/requests/<username>/deny", methods=["POST"]) @app.route("/api/admin/access/requests/<username>/deny", methods=["POST"])
@require_auth @require_auth
@ -128,27 +80,24 @@ def register(app) -> None:
ok, resp = require_portal_admin() ok, resp = require_portal_admin()
if not ok: if not ok:
return resp return resp
if not admin_client().ready(): if not configured():
return jsonify({"error": "server not configured"}), 503 return jsonify({"error": "server not configured"}), 503
user = admin_client().find_user(username) decided_by = getattr(g, "keycloak_username", "") or ""
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
try: 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: 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"]})

View File

@ -53,6 +53,8 @@ ACCOUNT_ALLOWED_GROUPS = [
if g.strip() 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_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()] 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("/") ).rstrip("/")
JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/") JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")

View File

@ -3,3 +3,4 @@ flask-cors==4.0.0
gunicorn==21.2.0 gunicorn==21.2.0
httpx==0.27.2 httpx==0.27.2
PyJWT[crypto]==2.10.1 PyJWT[crypto]==2.10.1
psycopg[binary]==3.2.6