portal: add access request approvals

This commit is contained in:
Brad Stein 2026-01-01 22:14:15 -03:00
parent 5a9a3b4f8b
commit c22c27b8aa
3 changed files with 640 additions and 8 deletions

View File

@ -2,6 +2,7 @@ from __future__ import annotations
import json import json
import os import os
import re
import time import time
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
@ -70,6 +71,13 @@ ACCOUNT_ALLOWED_GROUPS = [
if g.strip() 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_DOMAIN = os.getenv("MAILU_DOMAIN", "bstein.dev")
MAILU_SYNC_URL = os.getenv( MAILU_SYNC_URL = os.getenv(
"MAILU_SYNC_URL", "MAILU_SYNC_URL",
@ -80,6 +88,8 @@ JELLYFIN_SYNC_URL = os.getenv("JELLYFIN_SYNC_URL", "").rstrip("/")
_KEYCLOAK_JWK_CLIENT: PyJWKClient | None = None _KEYCLOAK_JWK_CLIENT: PyJWKClient | None = None
_KEYCLOAK_ADMIN_TOKEN: dict[str, Any] = {"token": "", "expires_at": 0.0} _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} _LAB_STATUS_CACHE: dict[str, Any] = {"ts": 0.0, "value": None}
@ -167,6 +177,20 @@ def require_auth(fn):
return wrapper 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"]) @app.route("/api/auth/config", methods=["GET"])
def auth_config() -> Any: def auth_config() -> Any:
if not KEYCLOAK_ENABLED: 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/<username>/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/<username>/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"]) @app.route("/api/account/mailu/rotate", methods=["POST"])
@require_auth @require_auth
def account_mailu_rotate() -> Any: def account_mailu_rotate() -> Any:

View File

@ -15,8 +15,9 @@
</div> </div>
</section> </section>
<section v-if="auth.ready && auth.authenticated" class="grid two"> <section v-if="auth.ready && auth.authenticated">
<div class="card module"> <div class="grid two">
<div class="card module">
<div class="module-head"> <div class="module-head">
<h2>Mail</h2> <h2>Mail</h2>
<span class="pill mono">{{ mailu.status }}</span> <span class="pill mono">{{ mailu.status }}</span>
@ -59,7 +60,7 @@
</div> </div>
</div> </div>
<div class="card module"> <div class="card module">
<div class="module-head"> <div class="module-head">
<h2>Jellyfin</h2> <h2>Jellyfin</h2>
<span class="pill mono">{{ jellyfin.status }}</span> <span class="pill mono">{{ jellyfin.status }}</span>
@ -94,6 +95,43 @@
<div class="mono">{{ jellyfin.error }}</div> <div class="mono">{{ jellyfin.error }}</div>
</div> </div>
</div> </div>
</div>
<div v-if="admin.enabled" class="card module admin">
<div class="module-head">
<h2>Admin Approvals</h2>
<span class="pill mono">{{ admin.loading ? "loading..." : `${admin.requests.length} pending` }}</span>
</div>
<p class="muted">Approve or deny pending access requests.</p>
<div v-if="admin.error" class="error-box">
<div class="mono">{{ admin.error }}</div>
</div>
<div v-if="!admin.loading && admin.requests.length === 0" class="muted">No pending requests.</div>
<div v-else class="requests">
<div class="req-head mono">
<span>User</span>
<span>Email</span>
<span>Note</span>
<span></span>
</div>
<div v-for="req in admin.requests" :key="req.username" class="req-row">
<div class="mono">{{ req.username }}</div>
<div class="mono">{{ req.email }}</div>
<div class="note">{{ req.note }}</div>
<div class="req-actions">
<button class="primary" type="button" :disabled="admin.acting[req.username]" @click="approve(req.username)">
approve
</button>
<button class="pill mono" type="button" :disabled="admin.acting[req.username]" @click="deny(req.username)">
deny
</button>
</div>
</div>
</div>
</div>
</section> </section>
<section v-else class="card module"> <section v-else class="card module">
@ -128,6 +166,14 @@ const jellyfin = reactive({
error: "", error: "",
}); });
const admin = reactive({
enabled: false,
loading: false,
requests: [],
error: "",
acting: {},
});
const doLogin = () => login("/account"); const doLogin = () => login("/account");
onMounted(async () => { onMounted(async () => {
@ -138,6 +184,7 @@ onMounted(async () => {
} }
await refreshOverview(); await refreshOverview();
await refreshAdminRequests();
}); });
async function refreshOverview() { 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() { async function rotateMailu() {
mailu.error = ""; mailu.error = "";
mailu.newPassword = ""; 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) { async function copy(text) {
if (!text) return; if (!text) return;
try { try {
@ -357,4 +461,68 @@ button.primary {
grid-template-columns: 1fr; 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;
}
}
</style> </style>

View File

@ -4,20 +4,120 @@
<div> <div>
<p class="eyebrow">Atlas</p> <p class="eyebrow">Atlas</p>
<h1>Request Access</h1> <h1>Request Access</h1>
<p class="lede">Self-serve signups are not enabled yet. Request access and an admin can approve your account.</p> <p class="lede">
Self-serve signups are not enabled yet. Request access and an admin can approve your account.
</p>
</div> </div>
</section> </section>
<section class="card module"> <section class="card module">
<h2>Not live yet</h2> <div class="module-head">
<h2>Request form</h2>
<span class="pill mono" :class="submitted ? 'pill-ok' : 'pill-warn'">
{{ submitted ? "submitted" : "pending" }}
</span>
</div>
<p class="muted"> <p class="muted">
This flow is being wired into Keycloak approvals. For now, email <span class="mono">brad@bstein.dev</span> with the This creates a pending request in Atlas. If approved, you'll receive an email with next steps.
username you want and a short note about what you need access to.
</p> </p>
<form class="form" @submit.prevent="submit" v-if="!submitted">
<label class="field">
<span class="label mono">Desired Username</span>
<input
v-model="form.username"
class="input mono"
type="text"
autocomplete="username"
placeholder="e.g. alice"
:disabled="submitting"
required
/>
</label>
<label class="field">
<span class="label mono">Email</span>
<input
v-model="form.email"
class="input mono"
type="email"
autocomplete="email"
placeholder="you@example.com"
:disabled="submitting"
required
/>
</label>
<label class="field">
<span class="label mono">Note (optional)</span>
<textarea
v-model="form.note"
class="textarea"
rows="4"
placeholder="What do you want access to?"
:disabled="submitting"
/>
</label>
<div class="actions">
<button class="primary" type="submit" :disabled="submitting || !form.username.trim() || !form.email.trim()">
{{ submitting ? "Submitting..." : "Submit request" }}
</button>
<span class="hint mono">Requests are rate-limited.</span>
</div>
</form>
<div v-else class="success-box">
<div class="mono">Request submitted.</div>
<div class="muted">If approved, you'll get an email to finish setup.</div>
</div>
<div v-if="error" class="error-box">
<div class="mono">{{ error }}</div>
</div>
</section> </section>
</div> </div>
</template> </template>
<script setup>
import { reactive, ref } from "vue";
const form = reactive({
username: "",
email: "",
note: "",
});
const submitting = ref(false);
const submitted = ref(false);
const error = ref("");
async function submit() {
if (submitting.value) return;
error.value = "";
submitting.value = true;
try {
const resp = await fetch("/api/access/request", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
username: form.username.trim(),
email: form.email.trim(),
note: form.note.trim(),
}),
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) throw new Error(data.error || resp.statusText || `status ${resp.status}`);
submitted.value = true;
} catch (err) {
error.value = err.message || "Failed to submit request";
} finally {
submitting.value = false;
}
}
</script>
<style scoped> <style scoped>
.page { .page {
max-width: 960px; max-width: 960px;
@ -56,6 +156,13 @@ h1 {
padding: 18px; padding: 18px;
} }
.module-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.muted { .muted {
color: var(--text-muted); color: var(--text-muted);
margin: 10px 0 0; margin: 10px 0 0;
@ -64,5 +171,81 @@ h1 {
.mono { .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
} }
</style>
.form {
margin-top: 14px;
display: grid;
gap: 12px;
}
.field {
display: grid;
gap: 6px;
}
.label {
color: var(--text-muted);
font-size: 12px;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.input,
.textarea {
width: 100%;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.22);
color: var(--text);
padding: 10px 12px;
outline: none;
}
.textarea {
resize: vertical;
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 6px;
}
.hint {
color: var(--text-muted);
font-size: 12px;
}
.error-box {
margin-top: 14px;
border: 1px solid rgba(255, 120, 120, 0.35);
background: rgba(255, 64, 64, 0.12);
border-radius: 14px;
padding: 12px;
}
.success-box {
margin-top: 14px;
border: 1px solid rgba(120, 255, 160, 0.25);
background: rgba(48, 255, 160, 0.1);
border-radius: 14px;
padding: 12px;
}
.pill {
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.2);
}
.pill-ok {
border-color: rgba(120, 255, 160, 0.3);
}
.pill-warn {
border-color: rgba(255, 220, 120, 0.25);
}
</style>