portal: store access requests in postgres
This commit is contained in:
parent
575932b32f
commit
456974b3c9
@ -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
|
||||
|
||||
|
||||
54
backend/atlas_portal/db.py
Normal file
54
backend/atlas_portal/db.py
Normal 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'
|
||||
"""
|
||||
)
|
||||
@ -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
|
||||
|
||||
@ -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"]})
|
||||
|
||||
@ -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("/")
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user