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 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
|
||||||
|
|
||||||
|
|||||||
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 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"})
|
|
||||||
|
|
||||||
|
|||||||
@ -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"]})
|
||||||
|
|||||||
@ -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("/")
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user