diff --git a/backend/app.py b/backend/app.py index 2fe1231..23487e9 100644 --- a/backend/app.py +++ b/backend/app.py @@ -2,6 +2,7 @@ from __future__ import annotations import json import os +import re import time from functools import wraps from pathlib import Path @@ -70,6 +71,13 @@ ACCOUNT_ALLOWED_GROUPS = [ if g.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()] + +ACCESS_REQUEST_ENABLED = os.getenv("ACCESS_REQUEST_ENABLED", "true").lower() in ("1", "true", "yes") +ACCESS_REQUEST_RATE_LIMIT = int(os.getenv("ACCESS_REQUEST_RATE_LIMIT", "5")) +ACCESS_REQUEST_RATE_WINDOW_SEC = int(os.getenv("ACCESS_REQUEST_RATE_WINDOW_SEC", str(60 * 60))) + MAILU_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev") MAILU_SYNC_URL = os.getenv( "MAILU_SYNC_URL", @@ -80,6 +88,8 @@ JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/") _KEYCLOAK_JWK_CLIENT: PyJWKClient | None = None _KEYCLOAK_ADMIN_TOKEN: dict[str, Any] = {"token": "", "expires_at": 0.0} +_KEYCLOAK_GROUP_ID_CACHE: dict[str, str] = {} +_ACCESS_REQUEST_RATE: dict[str, list[float]] = {} _LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None} @@ -167,6 +177,20 @@ def require_auth(fn): return wrapper +def _require_portal_admin() -> tuple[bool, Any]: + if not KEYCLOAK_ENABLED: + return False, (jsonify({"error": "keycloak not enabled"}), 503) + + username = getattr(g, "keycloak_username", "") or "" + groups = set(getattr(g, "keycloak_groups", []) or []) + + if username and username in PORTAL_ADMIN_USERS: + return True, None + if PORTAL_ADMIN_GROUPS and groups.intersection(PORTAL_ADMIN_GROUPS): + return True, None + return False, (jsonify({"error": "forbidden"}), 403) + + @app.route("/api/auth/config", methods=["GET"]) def auth_config() -> Any: if not KEYCLOAK_ENABLED: @@ -334,6 +358,263 @@ def account_overview() -> Any: ) +def _rate_limit_allow(ip: str) -> bool: + if ACCESS_REQUEST_RATE_LIMIT <= 0: + return True + now = time.time() + window_start = now - ACCESS_REQUEST_RATE_WINDOW_SEC + bucket = _ACCESS_REQUEST_RATE.setdefault(ip, []) + bucket[:] = [t for t in bucket if t >= window_start] + if len(bucket) >= ACCESS_REQUEST_RATE_LIMIT: + return False + bucket.append(now) + return True + + +def _kc_admin_create_user(payload: dict[str, Any]) -> str: + url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users" + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.post(url, headers={**_kc_admin_headers(), "Content-Type": "application/json"}, json=payload) + resp.raise_for_status() + location = resp.headers.get("Location") or "" + if location: + return location.rstrip("/").split("/")[-1] + raise RuntimeError("failed to determine created user id") + + +def _kc_admin_delete_user(user_id: str) -> None: + url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}" + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.delete(url, headers=_kc_admin_headers()) + resp.raise_for_status() + + +def _kc_admin_get_group_id(group_name: str) -> str | None: + cached = _KEYCLOAK_GROUP_ID_CACHE.get(group_name) + if cached: + return cached + + url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/groups" + params = {"search": group_name} + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.get(url, params=params, headers=_kc_admin_headers()) + resp.raise_for_status() + items = resp.json() + if not isinstance(items, list): + return None + for item in items: + if not isinstance(item, dict): + continue + if item.get("name") == group_name and item.get("id"): + gid = str(item["id"]) + _KEYCLOAK_GROUP_ID_CACHE[group_name] = gid + return gid + return None + + +def _kc_admin_add_user_to_group(user_id: str, group_id: str) -> None: + url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/groups/{quote(group_id, safe='')}" + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.put(url, headers=_kc_admin_headers()) + resp.raise_for_status() + + +def _kc_admin_execute_actions_email(user_id: str, actions: list[str]) -> None: + url = f"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users/{quote(user_id, safe='')}/execute-actions-email" + params = {"client_id": KEYCLOAK_CLIENT_ID, "redirect_uri": request.host_url.rstrip("/") + "/"} + with httpx.Client(timeout=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.put( + url, + params=params, + headers={**_kc_admin_headers(), "Content-Type": "application/json"}, + json=actions, + ) + resp.raise_for_status() + + +def _extract_request_payload() -> tuple[str, str, str]: + payload = request.get_json(silent=True) or {} + username = (payload.get("username") or "").strip() + email = (payload.get("email") or "").strip() + note = (payload.get("note") or "").strip() + return username, email, note + + +@app.route("/api/access/request", methods=["POST"]) +def request_access() -> Any: + if not ACCESS_REQUEST_ENABLED: + return jsonify({"error": "request access disabled"}), 503 + if not _kc_admin_ready(): + return jsonify({"error": "server not configured"}), 503 + + ip = request.remote_addr or "unknown" + if not _rate_limit_allow(ip): + return jsonify({"error": "rate limited"}), 429 + + username, email, note = _extract_request_payload() + if not username or not email: + return jsonify({"error": "username and email are required"}), 400 + + if len(username) < 3 or len(username) > 32: + return jsonify({"error": "username must be 3-32 characters"}), 400 + if not re.fullmatch(r"[a-zA-Z0-9._-]+", username): + return jsonify({"error": "username contains invalid characters"}), 400 + + if _kc_admin_find_user(username): + return jsonify({"error": "username already exists"}), 409 + + attrs: dict[str, Any] = { + "access_request_status": ["pending"], + "access_request_note": [note] if note else [""], + "access_request_created_at": [str(int(time.time()))], + } + + user_payload = { + "username": username, + "enabled": False, + "email": email, + "emailVerified": False, + "attributes": attrs, + } + try: + user_id = _kc_admin_create_user(user_payload) + except Exception: + return jsonify({"error": "failed to submit request"}), 502 + + return jsonify({"ok": True, "id": user_id}) + + +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"{KEYCLOAK_ADMIN_URL}/admin/realms/{KEYCLOAK_REALM}/users" + + # Prefer server-side search (q=) if supported by this Keycloak version. + # Always filter client-side for correctness. + 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=HTTP_CHECK_TIMEOUT_SEC) as client: + resp = client.get(url, params=params, headers=_kc_admin_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] + + +@app.route("/api/admin/access/requests", methods=["GET"]) +@require_auth +def admin_list_requests() -> Any: + ok, resp = _require_portal_admin() + if not ok: + return resp + if not _kc_admin_ready(): + return jsonify({"error": "server not configured"}), 503 + + try: + items = _kc_admin_list_pending_requests() + 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 = {} + output.append( + { + "id": user.get("id") or "", + "username": user.get("username") or "", + "email": user.get("email") or "", + "created_at": (attrs.get("access_request_created_at") or [""])[0], + "note": (attrs.get("access_request_note") or [""])[0], + } + ) + return jsonify({"requests": output}) + + +@app.route("/api/admin/access/requests//approve", methods=["POST"]) +@require_auth +def admin_approve_request(username: str) -> Any: + ok, resp = _require_portal_admin() + if not ok: + return resp + if not _kc_admin_ready(): + return jsonify({"error": "server not configured"}), 503 + + user = _kc_admin_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 = _kc_admin_get_user(user_id) + full["enabled"] = True + try: + _kc_admin_update_user(user_id, full) + except Exception: + return jsonify({"error": "failed to enable user"}), 502 + + group_id = _kc_admin_get_group_id("dev") + if group_id: + try: + _kc_admin_add_user_to_group(user_id, group_id) + except Exception: + pass + + try: + _kc_admin_execute_actions_email(user_id, ["UPDATE_PASSWORD"]) + except Exception: + pass + + return jsonify({"ok": True}) + + +@app.route("/api/admin/access/requests//deny", methods=["POST"]) +@require_auth +def admin_deny_request(username: str) -> Any: + ok, resp = _require_portal_admin() + if not ok: + return resp + if not _kc_admin_ready(): + return jsonify({"error": "server not configured"}), 503 + + user = _kc_admin_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 + + try: + _kc_admin_delete_user(user_id) + except Exception: + return jsonify({"error": "failed to delete user"}), 502 + + return jsonify({"ok": True}) + + @app.route("/api/account/mailu/rotate", methods=["POST"]) @require_auth def account_mailu_rotate() -> Any: diff --git a/frontend/src/views/AccountView.vue b/frontend/src/views/AccountView.vue index ec7f8e0..0d62ee4 100644 --- a/frontend/src/views/AccountView.vue +++ b/frontend/src/views/AccountView.vue @@ -15,8 +15,9 @@ -
-
+
+
+

Mail

{{ mailu.status }} @@ -59,7 +60,7 @@
-
+

Jellyfin

{{ jellyfin.status }} @@ -94,6 +95,43 @@
{{ jellyfin.error }}
+
+ +
+
+

Admin Approvals

+ {{ admin.loading ? "loading..." : `${admin.requests.length} pending` }} +
+

Approve or deny pending access requests.

+ +
+
{{ admin.error }}
+
+ +
No pending requests.
+ +
+
+ User + Email + Note + +
+
+
{{ req.username }}
+
{{ req.email }}
+
{{ req.note }}
+
+ + +
+
+
+
@@ -128,6 +166,14 @@ const jellyfin = reactive({ error: "", }); +const admin = reactive({ + enabled: false, + loading: false, + requests: [], + error: "", + acting: {}, +}); + const doLogin = () => login("/account"); onMounted(async () => { @@ -138,6 +184,7 @@ onMounted(async () => { } await refreshOverview(); + await refreshAdminRequests(); }); async function refreshOverview() { @@ -157,6 +204,29 @@ async function refreshOverview() { } } +async function refreshAdminRequests() { + admin.error = ""; + admin.loading = true; + try { + const resp = await authFetch("/api/admin/access/requests", { headers: { Accept: "application/json" } }); + if (resp.status === 403) { + admin.enabled = false; + admin.requests = []; + return; + } + if (!resp.ok) throw new Error(`status ${resp.status}`); + const data = await resp.json(); + admin.enabled = true; + admin.requests = Array.isArray(data.requests) ? data.requests : []; + } catch (err) { + admin.enabled = false; + admin.requests = []; + admin.error = err.message || "Failed to load access requests."; + } finally { + admin.loading = false; + } +} + async function rotateMailu() { mailu.error = ""; mailu.newPassword = ""; @@ -191,6 +261,40 @@ async function rotateJellyfin() { } } +async function approve(username) { + admin.error = ""; + admin.acting[username] = true; + try { + const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/approve`, { method: "POST" }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data.error || `status ${resp.status}`); + } + await refreshAdminRequests(); + } catch (err) { + admin.error = err.message || "Approve failed"; + } finally { + admin.acting[username] = false; + } +} + +async function deny(username) { + admin.error = ""; + admin.acting[username] = true; + try { + const resp = await authFetch(`/api/admin/access/requests/${encodeURIComponent(username)}/deny`, { method: "POST" }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error(data.error || `status ${resp.status}`); + } + await refreshAdminRequests(); + } catch (err) { + admin.error = err.message || "Deny failed"; + } finally { + admin.acting[username] = false; + } +} + async function copy(text) { if (!text) return; try { @@ -357,4 +461,68 @@ button.primary { grid-template-columns: 1fr; } } + +.admin { + margin-top: 12px; +} + +.requests { + margin-top: 12px; + display: grid; + gap: 10px; +} + +.req-head { + display: grid; + grid-template-columns: 160px 220px 1fr 140px; + gap: 12px; + color: var(--text-muted); + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.req-row { + display: grid; + grid-template-columns: 160px 220px 1fr 140px; + gap: 12px; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.08); + background: rgba(0, 0, 0, 0.18); + border-radius: 14px; + padding: 10px 12px; +} + +.note { + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.req-actions { + display: flex; + justify-content: flex-end; + gap: 8px; +} + +@media (max-width: 900px) { + .req-head { + display: none; + } + + .req-row { + grid-template-columns: 1fr; + gap: 8px; + align-items: start; + } + + .note { + white-space: normal; + } + + .req-actions { + justify-content: flex-start; + } +} diff --git a/frontend/src/views/RequestAccessView.vue b/frontend/src/views/RequestAccessView.vue index 21d336e..586deab 100644 --- a/frontend/src/views/RequestAccessView.vue +++ b/frontend/src/views/RequestAccessView.vue @@ -4,20 +4,120 @@

Atlas

Request Access

-

Self-serve signups are not enabled yet. Request access and an admin can approve your account.

+

+ Self-serve signups are not enabled yet. Request access and an admin can approve your account. +

-

Not live yet

+
+

Request form

+ + {{ submitted ? "submitted" : "pending" }} + +
+

- This flow is being wired into Keycloak approvals. For now, email brad@bstein.dev with the - username you want and a short note about what you need access to. + This creates a pending request in Atlas. If approved, you'll receive an email with next steps.

+ +
+ + + + +