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

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

View File

@ -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/<username>/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"]})

View File

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

View File

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